diff --git a/src/Command/GenerateManifestCommand.php b/src/Command/GenerateManifestCommand.php index fd67a09..3d47e00 100644 --- a/src/Command/GenerateManifestCommand.php +++ b/src/Command/GenerateManifestCommand.php @@ -9,7 +9,6 @@ use SpomkyLabs\PwaBundle\ImageProcessor\ImageProcessor; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -17,6 +16,7 @@ use Symfony\Component\Filesystem\Exception\IOExceptionInterface; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Finder\Finder; +use Symfony\Component\HttpKernel\Config\FileLocator; use Symfony\Component\Mime\MimeTypes; use Symfony\Component\Routing\RouterInterface; use function count; @@ -40,6 +40,7 @@ public function __construct( #[Autowire('%spomky_labs_pwa.dest%')] private readonly array $dest, private readonly Filesystem $filesystem, + private readonly FileLocator $fileLocator, private readonly ?RouterInterface $router = null, ) { $this->mime = MimeTypes::getDefault(); @@ -69,6 +70,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (! is_array($manifest)) { return self::FAILURE; } + $manifest = $this->processServiceWorker($io, $manifest); + if (! is_array($manifest)) { + return self::FAILURE; + } try { $this->createDirectoryIfNotExists(dirname((string) $this->dest['manifest_filepath'])); @@ -203,12 +208,7 @@ private function processIcons(SymfonyStyle $io, array $manifest): array|int return self::FAILURE; } $manifest['icons'] = []; - $progressBar = $io->createProgressBar(count($this->config['icons'])); - $progressBar->start(); - $io->info('Processing icons'); - $progressBar->start(); foreach ($this->config['icons'] as $icon) { - $this->processProgressBar($progressBar, 'icon', $icon['src']); foreach ($icon['sizes'] as $size) { if (! is_int($size) || $size < 0) { $io->error('The icon size must be a positive integer'); @@ -224,7 +224,6 @@ private function processIcons(SymfonyStyle $io, array $manifest): array|int $manifest['icons'][] = $iconManifest; } } - $progressBar->finish(); $io->info('Icons are built'); return $manifest; @@ -241,9 +240,6 @@ private function processScreenshots(SymfonyStyle $io, array $manifest): array|in return self::FAILURE; } $manifest['screenshots'] = []; - $progressBar = $io->createProgressBar(count($this->config['screenshots'])); - $progressBar->start(); - $io->info('Processing screenshots'); $config = []; foreach ($this->config['screenshots'] as $screenshot) { $src = $screenshot['src']; @@ -258,7 +254,6 @@ private function processScreenshots(SymfonyStyle $io, array $manifest): array|in } foreach ($config as $screenshot) { - $this->processProgressBar($progressBar, 'screenshot', $screenshot['src']); $data = $this->loadFileAndConvert($screenshot['src'], null, $screenshot['format'] ?? null); if ($data === null) { $io->error(sprintf('Unable to read the icon "%s"', $screenshot['src'])); @@ -277,7 +272,6 @@ private function processScreenshots(SymfonyStyle $io, array $manifest): array|in } $manifest['screenshots'][] = $screenshotManifest; } - $progressBar->finish(); return $manifest; } @@ -293,11 +287,7 @@ private function processShortcutIcons(SymfonyStyle $io, array|int $manifest): ar return self::FAILURE; } $manifest['shortcuts'] = []; - $progressBar = $io->createProgressBar(count($this->config['shortcuts'])); - $io->info('Processing shortcuts'); - $progressBar->start(); foreach ($this->config['shortcuts'] as $shortcutConfig) { - $this->processProgressBar($progressBar, 'shortcuts', $shortcutConfig['name']); $shortcut = $shortcutConfig; if (isset($shortcut['icons'])) { unset($shortcut['icons']); @@ -334,7 +324,6 @@ private function processShortcutIcons(SymfonyStyle $io, array|int $manifest): ar } $manifest['shortcuts'][] = $shortcut; } - $progressBar->finish(); $manifest['shortcuts'] = array_values($manifest['shortcuts']); return $manifest; @@ -376,24 +365,17 @@ private function createDirectoryIfNotExists(string $folder): bool return true; } - private function processProgressBar(ProgressBar $progressBar, string $type, string $src): void - { - $progressBar->advance(); - $progressBar->setMessage(sprintf('Processing %s %s', $type, $src)); - } - private function processActions(SymfonyStyle $io, array $manifest): array|int { if ($this->config['file_handlers'] === []) { return $manifest; } - $io->info('Processing file handlers'); 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'); + $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( @@ -427,4 +409,55 @@ private function findImages(string $src): iterable } } } + + 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; + } } diff --git a/src/Command/WorkboxInitCommand.php b/src/Command/WorkboxInitCommand.php deleted file mode 100644 index 5425402..0000000 --- a/src/Command/WorkboxInitCommand.php +++ /dev/null @@ -1,51 +0,0 @@ -title('Workbox Service Worker'); - - if (! $this->filesystem->exists(dirname((string) $this->dest['serviceworker_filepath']))) { - $this->filesystem->mkdir(dirname((string) $this->dest['serviceworker_filepath'])); - } - - $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, $this->dest['serviceworker_filepath']); - - $io->success('Workbox is ready to use!'); - - return self::SUCCESS; - } -} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 8ca62b0..2a4a6cc 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -42,6 +42,9 @@ private function setupShortcuts(ArrayNodeDefinition $node): void { $node->children() ->arrayNode('shortcuts') + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) ->info('The shortcuts of the application.') ->arrayPrototype() ->children() @@ -75,6 +78,9 @@ private function setupScreenshots(ArrayNodeDefinition $node): void { $node->children() ->arrayNode('screenshots') + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) ->info('The screenshots of the application.') ->arrayPrototype() ->children() @@ -113,6 +119,9 @@ private function setupFileHandlers(ArrayNodeDefinition $node): void ->info( 'It specifies an array of objects representing the types of files an installed progressive web app (PWA) can handle.' ) + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) ->arrayPrototype() ->children() ->scalarNode('action') @@ -140,6 +149,9 @@ private function setupSharedTarget(ArrayNodeDefinition $node): void { $node->children() ->arrayNode('share_target') + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) ->info('The share target of the application.') ->children() ->scalarNode('action') @@ -195,6 +207,9 @@ private function setupProtocolHandlers(ArrayNodeDefinition $node): void { $node->children() ->arrayNode('protocol_handlers') + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) ->info('The protocol handlers of the application.') ->arrayPrototype() ->children() @@ -218,6 +233,9 @@ private function setupLaunchHandler(ArrayNodeDefinition $node): void { $node->children() ->arrayNode('launch_handler') + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) ->info('The launch handler of the application.') ->children() ->arrayNode('client_mode') @@ -237,9 +255,12 @@ private function setupRelatedApplications(ArrayNodeDefinition $node): void { $node->children() ->booleanNode('prefer_related_applications') - ->info('The prefer related native applications of the application.') + ->info('The prefer related native applications of the application.') ->end() ->arrayNode('related_applications') + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) ->info('The related applications of the application.') ->arrayPrototype() ->children() @@ -297,13 +318,9 @@ private function setupSimpleOptions(ArrayNodeDefinition $node): void ->info('The URL prefix to use to generate the icons.') ->end() ->scalarNode('manifest_filepath') - ->defaultValue('%kernel.project_dir%/public/pwa.json') + ->defaultValue('%kernel.project_dir%/public/site.webmanifest') ->info('The filename where the manifest will be generated.') ->end() - ->scalarNode('serviceworker_filepath') - ->defaultValue('%kernel.project_dir%/public/sw.js') - ->info('The filename where the service worker will be generated.') - ->end() ->scalarNode('background_color') ->info( 'The background color of the application. It should match the background-color CSS property in the sites stylesheet for a smooth transition between launching the web application and loading the site\'s content.' @@ -374,6 +391,9 @@ private function getIconsNode(): ArrayNodeDefinition $node = $treeBuilder->getRootNode(); assert($node instanceof ArrayNodeDefinition); $node + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) ->arrayPrototype() ->children() ->scalarNode('src') @@ -407,19 +427,41 @@ private function setupServiceWorker(ArrayNodeDefinition $node): void { $node->children() ->arrayNode('serviceworker') - ->info( - 'EXPERIMENTAL. Specifies a serviceworker that is Just-In-Time (JIT)-installed and registered to run a web-based payment app providing a payment mechanism for a specified payment method in a merchant website.' - ) + ->info('EXPERIMENTAL. Specifies a serviceworker that is registered.') + ->treatFalseLike([]) + ->treatTrueLike([ + 'generate' => true, + ]) + ->treatNullLike([]) + ->validate() + ->ifTrue( + static fn (array $v): bool => basename((string) $v['filepath']) !== basename((string) $v['src']) + ) + ->thenInvalid('The filename from the "filepath" and the "src" must be the same.') + ->end() ->children() + ->booleanNode('generate') + ->defaultFalse() + ->info('Whether the service worker should be generated.') + ->end() + ->scalarNode('filepath') + ->defaultValue('%kernel.project_dir%/public/sw.js') + ->info('The filename where the service worker will be generated.') + ->end() ->scalarNode('src') + ->cannotBeEmpty() + ->defaultValue('/sw.js') ->info('The path to the service worker.') - ->example('sw.js') + ->example('/sw.js') ->end() ->scalarNode('scope') + ->cannotBeEmpty() + ->defaultValue('/') ->info('The scope of the service worker.') ->example('/app/') ->end() ->booleanNode('use_cache') + ->defaultTrue() ->info('Whether the service worker should use the cache.') ->end() ->end() diff --git a/tests/Functional/CommandTest.php b/tests/Functional/CommandTest.php index 211fe62..0a5acc6 100644 --- a/tests/Functional/CommandTest.php +++ b/tests/Functional/CommandTest.php @@ -42,27 +42,14 @@ public static function theCommandCanGenerateTheManifestAndIcons(): void // Then $commandTester->assertCommandIsSuccessful(); + static::assertStringContainsString('PWA Manifest Generator', $commandTester->getDisplay()); - static::assertFileExists(sprintf('%s/samples/manifest/my-pwa.json', self::$kernel->getCacheDir())); static::assertDirectoryExists(sprintf('%s/samples/icons', self::$kernel->getCacheDir())); static::assertDirectoryExists(sprintf('%s/samples/screenshots', self::$kernel->getCacheDir())); static::assertDirectoryExists(sprintf('%s/samples/shortcut_icons', self::$kernel->getCacheDir())); - } - - #[Test] - public static function theCommandCanCreateTheServiceWorker(): void - { - // Given - $command = self::$application->find('pwa:sw'); - $commandTester = new CommandTester($command); - - // When - $commandTester->execute([]); - - // Then - $commandTester->assertCommandIsSuccessful(); - static::assertStringContainsString('Workbox Service Worker', $commandTester->getDisplay()); - static::assertFileExists(sprintf('%s/samples/sw/my-sw.js', self::$kernel->getCacheDir())); + foreach (self::expectedFiles() as $name => $file) { + static::assertFileExists($file, sprintf('File "%s" does not exist.', $name)); + } } private static function cleanupFolder(): void @@ -70,4 +57,87 @@ private static function cleanupFolder(): void $filesystem = self::getContainer()->get(Filesystem::class); $filesystem->remove(sprintf('%s/samples', self::$kernel->getCacheDir())); } + + /** + * @return iterable + */ + private static function expectedFiles(): iterable + { + yield 'sw' => sprintf('%s/samples/sw/my-sw.js', self::$kernel->getCacheDir()); + yield 'manifest' => sprintf('%s/samples/manifest/my-pwa.json', self::$kernel->getCacheDir()); + yield 'icon 48' => sprintf('%s/samples/icons/icon--48x48-1dc988f5.json', self::$kernel->getCacheDir()); + yield 'icon 72' => sprintf('%s/samples/icons/icon--72x72-5446402b.json', self::$kernel->getCacheDir()); + yield 'icon 96' => sprintf('%s/samples/icons/icon--96x96-d6d73d91.json', self::$kernel->getCacheDir()); + yield 'icon 128' => sprintf('%s/samples/icons/icon--128x128-d7e6af19.json', self::$kernel->getCacheDir()); + yield 'icon 256' => sprintf('%s/samples/icons/icon--256x256-0091eae5.json', self::$kernel->getCacheDir()); + yield 'icon any' => sprintf('%s/samples/icons/icon--any-2a9c5120.svg', self::$kernel->getCacheDir()); + yield 'icon 48 maskable' => sprintf( + '%s/samples/icons/icon-maskable-48x48-bda4c927.json', + self::$kernel->getCacheDir() + ); + yield 'icon 72 maskable' => sprintf( + '%s/samples/icons/icon-maskable-72x72-6019b5fd.json', + self::$kernel->getCacheDir() + ); + yield 'icon 96 maskable' => sprintf( + '%s/samples/icons/icon-maskable-96x96-b4c4250c.json', + self::$kernel->getCacheDir() + ); + yield 'icon 128 maskable' => sprintf( + '%s/samples/icons/icon-maskable-128x128-9be87901.json', + self::$kernel->getCacheDir() + ); + yield 'icon 256 maskable' => sprintf( + '%s/samples/icons/icon-maskable-256x256-8f61caf3.json', + self::$kernel->getCacheDir() + ); + yield 'icon 48' => sprintf( + '%s/samples/shortcut_icons/shortcut-icon--48x48-1dc988f5.json', + self::$kernel->getCacheDir() + ); + yield 'icon 72' => sprintf( + '%s/samples/shortcut_icons/shortcut-icon--72x72-5446402b.json', + self::$kernel->getCacheDir() + ); + yield 'icon 96' => sprintf( + '%s/samples/shortcut_icons/shortcut-icon--96x96-d6d73d91.json', + self::$kernel->getCacheDir() + ); + yield 'icon 128' => sprintf( + '%s/samples/shortcut_icons/shortcut-icon--128x128-d7e6af19.json', + self::$kernel->getCacheDir() + ); + yield 'icon 256' => sprintf( + '%s/samples/shortcut_icons/shortcut-icon--256x256-0091eae5.json', + self::$kernel->getCacheDir() + ); + yield 'icon any' => sprintf( + '%s/samples/shortcut_icons/shortcut-icon--any-2a9c5120.svg', + self::$kernel->getCacheDir() + ); + yield 'icon 48 maskable' => sprintf( + '%s/samples/shortcut_icons/shortcut-icon-maskable-48x48-bda4c927.json', + self::$kernel->getCacheDir() + ); + yield 'icon 72 maskable' => sprintf( + '%s/samples/shortcut_icons/shortcut-icon-maskable-72x72-6019b5fd.json', + self::$kernel->getCacheDir() + ); + yield 'icon 96 maskable' => sprintf( + '%s/samples/shortcut_icons/shortcut-icon-maskable-96x96-b4c4250c.json', + self::$kernel->getCacheDir() + ); + yield 'icon 128 maskable' => sprintf( + '%s/samples/shortcut_icons/shortcut-icon-maskable-128x128-9be87901.json', + self::$kernel->getCacheDir() + ); + yield 'icon 256 maskable' => sprintf( + '%s/samples/shortcut_icons/shortcut-icon-maskable-256x256-8f61caf3.json', + self::$kernel->getCacheDir() + ); + yield 'screenshot' => sprintf( + '%s/samples/screenshots/screenshot--1024x1920-a8c03e1d.json', + self::$kernel->getCacheDir() + ); + } } diff --git a/tests/config.php b/tests/config.php index 42247aa..03e424d 100644 --- a/tests/config.php +++ b/tests/config.php @@ -26,7 +26,6 @@ 'screenshot_folder' => '%kernel.cache_dir%/samples/screenshots', 'screenshot_prefix_url' => '/screenshots', 'manifest_filepath' => '%kernel.cache_dir%/samples/manifest/my-pwa.json', - 'serviceworker_filepath' => '%kernel.cache_dir%/samples/sw/my-sw.js', 'background_color' => 'red', 'categories' => ['books', 'education', 'medical'], 'description' => 'Awesome application that will help you achieve your dreams.', @@ -152,5 +151,12 @@ ], ], ], + 'serviceworker' => [ + 'generate' => true, + 'src' => '/my-sw.js', + 'filepath' => '%kernel.cache_dir%/samples/sw/my-sw.js', + 'scope' => '/', + 'use_cache' => true, + ], ]); };