From 5e59efef12ef2a8d94ed37480e375ea9f2d20054 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Wed, 10 Jan 2024 14:20:14 +0100 Subject: [PATCH] Adds Windows10/11 Widgets, edge_side_panel, iarc_rating_id, scope_extensions and handle_links parameters --- src/Command/GenerateManifestCommand.php | 128 ++++++++++++---------- src/DependencyInjection/Configuration.php | 124 ++++++++++++++++++--- tests/Functional/CommandTest.php | 45 -------- tests/config.php | 44 +++++++- 4 files changed, 222 insertions(+), 119 deletions(-) diff --git a/src/Command/GenerateManifestCommand.php b/src/Command/GenerateManifestCommand.php index 782e39a..48d506a 100644 --- a/src/Command/GenerateManifestCommand.php +++ b/src/Command/GenerateManifestCommand.php @@ -30,7 +30,7 @@ use const JSON_UNESCAPED_SLASHES; use const JSON_UNESCAPED_UNICODE; -#[AsCommand(name: 'pwa:build', description: 'Generate the Progressive Web App Manifest',)] +#[AsCommand(name: 'pwa:build', description: 'Generate the Progressive Web App Manifest')] final class GenerateManifestCommand extends Command { private readonly MimeTypes $mime; @@ -80,6 +80,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int 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; @@ -179,21 +183,6 @@ private function handleSizeAndPurpose(?string $purpose, int $size, array $fileDa return $fileData; } - /** - * @return array{src: string, sizes: string, type: string, purpose: ?string} - */ - private function storeShortcutIcon(string $data, int $size, ?string $purpose): array - { - $fileData = $this->storeFile( - $data, - $this->dest['shortcut_icon_prefix_url'], - $this->dest['shortcut_icon_folder'], - ['shortcut-icon', $purpose, $size === 0 ? 'any' : $size . 'x' . $size] - ); - - return $this->handleSizeAndPurpose($purpose, $size, $fileData); - } - /** * @return array{src: string, sizes: string, type: string, purpose: ?string} */ @@ -209,16 +198,13 @@ private function storeIcon(string $data, int $size, ?string $purpose): array return $this->handleSizeAndPurpose($purpose, $size, $fileData); } - private function processIcons(SymfonyStyle $io, array $manifest): array|int + /** + * @param array{src: string, sizes: array, format: ?string, purpose: ?string} $icons + */ + private function processIcon(SymfonyStyle $io, array $icons): array|int { - if ($this->config['icons'] === []) { - return $manifest; - } - if (! $this->createDirectoryIfNotExists($this->dest['icon_folder']) || ! $this->checkImageProcessor($io)) { - return self::FAILURE; - } - $manifest['icons'] = []; - foreach ($this->config['icons'] as $icon) { + $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'); @@ -231,27 +217,31 @@ private function processIcons(SymfonyStyle $io, array $manifest): array|int } $iconManifest = $this->storeIcon($data, $size, $icon['purpose'] ?? null); - $manifest['icons'][] = $iconManifest; + $result[] = $iconManifest; } } - $io->info('Icons are built'); - return $manifest; + return $result; } - private function processScreenshots(SymfonyStyle $io, array $manifest): array|int + private function processIcons(SymfonyStyle $io, array $manifest): array|int { - if ($this->config['screenshots'] === []) { + if ($this->config['icons'] === []) { return $manifest; } - if (! $this->createDirectoryIfNotExists($this->dest['screenshot_folder']) || ! $this->checkImageProcessor( - $io - )) { + if (! $this->createDirectoryIfNotExists($this->dest['icon_folder']) || ! $this->checkImageProcessor($io)) { return self::FAILURE; } - $manifest['screenshots'] = []; + $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 ($this->config['screenshots'] as $screenshot) { + foreach ($screenshots as $screenshot) { if (isset($screenshot['src'])) { $src = $screenshot['src']; if (! $this->filesystem->exists($src)) { @@ -264,6 +254,12 @@ private function processScreenshots(SymfonyStyle $io, array $manifest): array|in } } 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']; @@ -286,6 +282,7 @@ private function processScreenshots(SymfonyStyle $io, array $manifest): array|in } } + $result = []; foreach ($config as $screenshot) { $data = $this->loadFileAndConvert($screenshot['src'], null, $screenshot['format'] ?? null); if ($data === null) { @@ -305,12 +302,27 @@ private function processScreenshots(SymfonyStyle $io, array $manifest): array|in if (isset($screenshot['platform'])) { $screenshotManifest['platform'] = $screenshot['platform']; } - $manifest['screenshots'][] = $screenshotManifest; + $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; } @@ -319,9 +331,7 @@ private function processShortcutIcons(SymfonyStyle $io, array|int $manifest): ar if ($this->config['shortcuts'] === []) { return $manifest; } - if (! $this->createDirectoryIfNotExists($this->dest['shortcut_icon_folder']) || ! $this->checkImageProcessor( - $io - )) { + if (! $this->createDirectoryIfNotExists($this->dest['icon_folder']) || ! $this->checkImageProcessor($io)) { return self::FAILURE; } $manifest['shortcuts'] = []; @@ -339,26 +349,7 @@ private function processShortcutIcons(SymfonyStyle $io, array|int $manifest): ar } if (isset($shortcutConfig['icons'])) { - if (! $this->checkImageProcessor($io)) { - return self::FAILURE; - } - foreach ($shortcutConfig['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->storeShortcutIcon($data, $size, $icon['purpose'] ?? null); - $shortcut['icons'][] = $iconManifest; - } - } + $shortcut['icons'] = $this->processIcon($io, $shortcutConfig['icons']); } $manifest['shortcuts'][] = $shortcut; } @@ -498,4 +489,23 @@ private function processServiceWorker(SymfonyStyle $io, array $manifest): int|ar 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/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 8e162cc..e4d39e4 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -33,6 +33,7 @@ public function getConfigTreeBuilder(): TreeBuilder $this->setupRelatedApplications($rootNode); $this->setupShortcuts($rootNode); $this->setupSharedTarget($rootNode); + $this->setupWidgets($rootNode); $this->setupServiceWorker($rootNode); return $treeBuilder; @@ -66,7 +67,7 @@ private function setupShortcuts(ArrayNodeDefinition $node): void ->info('The URL of the shortcut.') ->example('https://example.com') ->end() - ->append($this->getIconsNode()) + ->append($this->getIconsNode('The icons of the shortcut.')) ->end() ->end() ->end() @@ -77,7 +78,7 @@ private function setupShortcuts(ArrayNodeDefinition $node): void private function setupScreenshots(ArrayNodeDefinition $node): void { $node->children() - ->append($this->getScreenshotsNode()) + ->append($this->getScreenshotsNode('The screenshots of the application.')) ->end() ; } @@ -168,7 +169,7 @@ private function setupSharedTarget(ArrayNodeDefinition $node): void private function setupIcons(ArrayNodeDefinition $node): void { $node->children() - ->append($this->getIconsNode()) + ->append($this->getIconsNode('The icons of the application.')) ->end() ; } @@ -275,14 +276,6 @@ private function setupSimpleOptions(ArrayNodeDefinition $node): void ->defaultValue('/pwa') ->info('The URL prefix to use to generate the icons.') ->end() - ->scalarNode('shortcut_icon_folder') - ->defaultValue('%kernel.project_dir%/public/pwa') - ->info('The folder where the shortcut icons will be generated.') - ->end() - ->scalarNode('shortcut_icon_prefix_url') - ->defaultValue('/pwa') - ->info('The URL prefix to use to generate the icons.') - ->end() ->scalarNode('screenshot_folder') ->defaultValue('%kernel.project_dir%/public/pwa') ->info('The folder where the screenshots will be generated.') @@ -355,16 +348,48 @@ private function setupSimpleOptions(ArrayNodeDefinition $node): void ->info('The theme color of the application.') ->example('red') ->end() + ->arrayNode('edge_side_panel') + ->info('Specifies whether or not your app supports the side panel view in Microsoft Edge.') + ->children() + ->integerNode('preferred_width') + ->info('Specifies the preferred width of the side panel view in Microsoft Edge.') + ->end() + ->end() + ->end() + ->scalarNode('iarc_rating_id') + ->info( + 'Specifies the International Age Rating Coalition (IARC) rating ID for the app. See https://www.globalratings.com/how-iarc-works.aspx for more information.' + ) + ->end() + ->arrayNode('scope_extensions') + ->info( + 'Specifies a list of origin patterns to associate with. This allows for your app to control multiple subdomains and top-level domains as a single entity.' + ) + ->arrayPrototype() + ->children() + ->scalarNode('origin') + ->isRequired() + ->info('Specifies the origin pattern to associate with.') + ->example('*.foo.com') + ->end() + ->end() + ->end() + ->end() + ->scalarNode('handle_links') + ->info('Specifies the default link handling for the web app.') + ->example(['auto', 'preferred', 'not-preferred']) + ->end() ->end() ; } - private function getIconsNode(): ArrayNodeDefinition + private function getIconsNode(string $info): ArrayNodeDefinition { $treeBuilder = new TreeBuilder('icons'); $node = $treeBuilder->getRootNode(); assert($node instanceof ArrayNodeDefinition); $node + ->info($info) ->treatFalseLike([]) ->treatTrueLike([]) ->treatNullLike([]) @@ -443,12 +468,13 @@ private function setupServiceWorker(ArrayNodeDefinition $node): void ->end(); } - private function getScreenshotsNode(): ArrayNodeDefinition + private function getScreenshotsNode(string $info): ArrayNodeDefinition { $treeBuilder = new TreeBuilder('screenshots'); $node = $treeBuilder->getRootNode(); assert($node instanceof ArrayNodeDefinition); $node + ->info($info) ->treatFalseLike([]) ->treatTrueLike([]) ->treatNullLike([]) @@ -513,4 +539,76 @@ private function getScreenshotsNode(): ArrayNodeDefinition return $node; } + + private function setupWidgets(ArrayNodeDefinition $node): void + { + $node->children() + ->arrayNode('widgets') + ->info( + 'EXPERIMENTAL. Specifies PWA-driven widgets. See https://learn.microsoft.com/en-us/microsoft-edge/progressive-web-apps-chromium/how-to/widgets for more information' + ) + ->arrayPrototype() + ->children() + ->scalarNode('name') + ->isRequired() + ->info('The title of the widget, presented to users.') + ->end() + ->scalarNode('short_name') + ->info('An alternative short version of the name.') + ->end() + ->scalarNode('description') + ->isRequired() + ->info('The description of the widget.') + ->example('My awesome widget') + ->end() + ->append( + $this->getIconsNode( + 'An array of icons to be used for the widget. If missing, the icons manifest member is used instead. Icons larger than 1024x1024 are ignored.' + ) + ) + ->append( + $this->getScreenshotsNode('The screenshots of the widget')->requiresAtLeastOneElement() + ) + ->scalarNode('tag') + ->isRequired() + ->info('A string used to reference the widget in the PWA service worker.') + ->end() + ->scalarNode('template') + ->info( + 'The template to use to display the widget in the operating system widgets dashboard. Note: this property is currently only informational and not used. See ms_ac_template below.' + ) + ->end() + ->scalarNode('ms_ac_template') + ->isRequired() + ->info( + 'The URL of the custom Adaptive Cards template to use to display the widget in the operating system widgets dashboard. See Define a widget template below.' + ) + ->end() + ->scalarNode('data') + ->info( + 'The URL where the data to fill the template with can be found. If present, this URL is required to return valid JSON.' + ) + ->end() + ->scalarNode('type') + ->info('The MIME type for the widget data.') + ->end() + ->booleanNode('auth') + ->info('A boolean indicating if the widget requires authentication.') + ->end() + ->integerNode('update') + ->info( + 'The frequency, in seconds, at which the widget will be updated. Code in your service worker must perform the updating; the widget is not updated automatically. See Access widget instances at runtime.' + ) + ->end() + ->booleanNode('multiple') + ->defaultTrue() + ->info( + 'A boolean indicating whether to allow multiple instances of the widget. Defaults to true.' + ) + ->end() + ->end() + ->end() + ->end() + ->end(); + } } diff --git a/tests/Functional/CommandTest.php b/tests/Functional/CommandTest.php index 0a5acc6..3296389 100644 --- a/tests/Functional/CommandTest.php +++ b/tests/Functional/CommandTest.php @@ -46,7 +46,6 @@ public static function theCommandCanGenerateTheManifestAndIcons(): void static::assertStringContainsString('PWA Manifest Generator', $commandTester->getDisplay()); 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())); foreach (self::expectedFiles() as $name => $file) { static::assertFileExists($file, sprintf('File "%s" does not exist.', $name)); } @@ -91,50 +90,6 @@ private static function expectedFiles(): iterable '%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 03e424d..349dccb 100644 --- a/tests/config.php +++ b/tests/config.php @@ -21,8 +21,6 @@ 'image_processor' => DummyImageProcessor::class, 'icon_folder' => '%kernel.cache_dir%/samples/icons', 'icon_prefix_url' => '/icons', - 'shortcut_icon_folder' => '%kernel.cache_dir%/samples/shortcut_icons', - 'shortcut_icon_prefix_url' => '/shortcut_icons', 'screenshot_folder' => '%kernel.cache_dir%/samples/screenshots', 'screenshot_prefix_url' => '/screenshots', 'manifest_filepath' => '%kernel.cache_dir%/samples/manifest/my-pwa.json', @@ -151,6 +149,48 @@ ], ], ], + 'edge_side_panel' => [ + 'preferred_width' => 480, + ], + 'iarc_rating_id' => '123456', + 'scope_extensions' => [ + [ + 'origin' => '*.foo.com', + ], + [ + 'origin' => 'https://*.bar.com', + ], + [ + 'origin' => 'https://*.baz.com', + ], + ], + 'widgets' => [ + [ + 'name' => 'PWAmp mini player', + 'description' => 'widget to control the PWAmp music player', + 'tag' => 'pwamp', + 'template' => 'pwamp-template', + 'ms_ac_template' => 'widgets/mini-player-template.json', + 'data' => 'widgets/mini-player-data.json', + 'type' => 'application/json', + 'screenshots' => [ + [ + 'src' => sprintf('%s/images/1920x1920.svg', __DIR__), + 'label' => 'The PWAmp mini-player widget', + ], + ], + 'icons' => [ + [ + 'src' => sprintf('%s/images/1920x1920.svg', __DIR__), + 'sizes' => [16, 48], + 'format' => 'webp', + ], + ], + 'auth' => false, + 'update' => 86400, + ], + ], + 'handle_links' => 'auto', 'serviceworker' => [ 'generate' => true, 'src' => '/my-sw.js',