From 34bbc68c7d765063bc48d25b2e148c85c6ab89e4 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Fri, 2 Feb 2024 20:37:45 +0100 Subject: [PATCH] Optional Manifest file --- src/DependencyInjection/Configuration.php | 921 +++++++++--------- .../SpomkyLabsPwaExtension.php | 26 +- src/Resources/config/services.php | 26 +- src/Service/ManifestBuilder.php | 2 +- src/Service/ServiceWorkerBuilder.php | 256 +---- src/Service/ServiceWorkerCompiler.php | 268 +++++ src/Subscriber/AssetsCompileEventListener.php | 7 +- src/Subscriber/PwaDevServerSubscriber.php | 31 +- .../ServiceWorkerCompileEventListener.php | 13 +- .../WorkboxCompileEventListener.php | 5 + src/Twig/PwaRuntime.php | 2 +- tests/Controller/DummyController.php | 10 + tests/Functional/DevServerTest.php | 1 + tests/config.php | 308 +++--- 14 files changed, 1000 insertions(+), 876 deletions(-) create mode 100644 src/Service/ServiceWorkerCompiler.php diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 9931403..36f00f6 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -27,225 +27,14 @@ public function getConfigTreeBuilder(): TreeBuilder assert($rootNode instanceof ArrayNodeDefinition); $rootNode->addDefaultsIfNotSet(); - $this->setupSimpleOptions($rootNode); - $this->setupIcons($rootNode); - $this->setupScreenshots($rootNode); - $this->setupFileHandlers($rootNode); - $this->setupLaunchHandler($rootNode); - $this->setupProtocolHandlers($rootNode); - $this->setupRelatedApplications($rootNode); - $this->setupShortcuts($rootNode); - $this->setupSharedTarget($rootNode); - $this->setupWidgets($rootNode); + $this->setupServices($rootNode); + $this->setupManifest($rootNode); $this->setupServiceWorker($rootNode); return $treeBuilder; } - private function setupShortcuts(ArrayNodeDefinition $node): void - { - $node->children() - ->arrayNode('shortcuts') - ->treatFalseLike([]) - ->treatTrueLike([]) - ->treatNullLike([]) - ->info('The shortcuts of the application.') - ->arrayPrototype() - ->children() - ->scalarNode('name') - ->isRequired() - ->info('The name of the shortcut.') - ->example('Awesome shortcut') - ->end() - ->scalarNode('short_name') - ->info('The short name of the shortcut.') - ->example('Awesome shortcut') - ->end() - ->scalarNode('description') - ->info('The description of the shortcut.') - ->example('Awesome shortcut') - ->end() - ->append($this->getUrlNode('url', 'The URL of the shortcut.')) - ->append($this->getIconsNode('The icons of the shortcut.')) - ->end() - ->end() - ->end() - ->end() - ; - } - - private function setupScreenshots(ArrayNodeDefinition $node): void - { - $node->children() - ->append($this->getScreenshotsNode('The screenshots of the application.')) - ->end() - ; - } - - private function setupFileHandlers(ArrayNodeDefinition $node): void - { - $node->children() - ->arrayNode('file_handlers') - ->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() - ->append($this->getUrlNode('action', 'The action to take.', ['/handle-audio-file'])) - ->arrayNode('accept') - ->requiresAtLeastOneElement() - ->useAttributeAsKey('name') - ->arrayPrototype() - ->scalarPrototype()->end() - ->end() - ->info('The file types that the action will be applied to.') - ->example('image/*') - ->end() - ->end() - ->end() - ->end() - ->end() - ; - } - - private function setupSharedTarget(ArrayNodeDefinition $node): void - { - $node->children() - ->arrayNode('share_target') - ->treatFalseLike([]) - ->treatTrueLike([]) - ->treatNullLike([]) - ->info('The share target of the application.') - ->children() - ->append( - $this->getUrlNode('action', 'The action of the share target.', ['/shared-content-receiver/']) - ) - ->scalarNode('method') - ->info('The method of the share target.') - ->example('GET') - ->end() - ->scalarNode('enctype') - ->info('The enctype of the share target. Ignored if method is GET.') - ->example('multipart/form-data') - ->end() - ->arrayNode('params') - ->isRequired() - ->info('The parameters of the share target.') - ->children() - ->scalarNode('title') - ->info('The title of the share target.') - ->example('name') - ->end() - ->scalarNode('text') - ->info('The text of the share target.') - ->example('description') - ->end() - ->scalarNode('url') - ->info('The URL of the share target.') - ->example('link') - ->end() - ->arrayNode('files') - ->info('The files of the share target.') - ->scalarPrototype()->end() - ->end() - ->end() - ->end() - ->end() - ->end() - ->end() - ; - } - - private function setupIcons(ArrayNodeDefinition $node): void - { - $node->children() - ->append($this->getIconsNode('The icons of the application.')) - ->end() - ; - } - - private function setupProtocolHandlers(ArrayNodeDefinition $node): void - { - $node->children() - ->arrayNode('protocol_handlers') - ->treatFalseLike([]) - ->treatTrueLike([]) - ->treatNullLike([]) - ->info('The protocol handlers of the application.') - ->arrayPrototype() - ->children() - ->scalarNode('protocol') - ->isRequired() - ->info('The protocol of the handler.') - ->example('web+jngl') - ->end() - ->append($this->getUrlNode('url', 'The URL of the handler.')) - ->end() - ->end() - ->end() - ; - } - - 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') - ->info('The client mode of the application.') - ->example(['focus-existing', 'auto']) - ->scalarPrototype()->end() - ->beforeNormalization() - ->castToArray() - ->end() - ->end() - ->end() - ->end() - ; - } - - private function setupRelatedApplications(ArrayNodeDefinition $node): void - { - $node->children() - ->booleanNode('prefer_related_applications') - ->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() - ->scalarNode('platform') - ->isRequired() - ->info('The platform of the application.') - ->example('play') - ->end() - ->append( - $this->getUrlNode('url', 'The URL of the application.', [ - 'https://play.google.com/store/apps/details?id=com.example.app1', - ]) - ) - ->scalarNode('id') - ->info('The ID of the application.') - ->example('com.example.app1') - ->end() - ->end() - ->end() - ->end() - ->end() - ; - } - - private function setupSimpleOptions(ArrayNodeDefinition $node): void + private function setupServices(ArrayNodeDefinition $node): void { $node->children() ->integerNode('path_type_reference') @@ -273,12 +62,6 @@ private function setupSimpleOptions(ArrayNodeDefinition $node): void ->thenInvalid('Invalid path type reference "%s".') ->end() ->end() - ->scalarNode('manifest_public_url') - ->defaultValue('/site.webmanifest') - ->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.') @@ -288,173 +71,20 @@ private function setupSimpleOptions(ArrayNodeDefinition $node): void ->defaultNull() ->info('The Panther Client for generating screenshots. If not set, the default client will be used.') ->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.' - ) - ->example('red') - ->end() - ->arrayNode('categories') - ->info('The categories of the application.') - ->example([['news', 'sports', 'lifestyle']]) - ->scalarPrototype()->end() - ->end() - ->scalarNode('description') - ->info('The description of the application.') - ->example('My awesome application') - ->end() - ->scalarNode('display') - ->info('The display mode of the application.') - ->example('standalone') - ->end() - ->arrayNode('display_override') - ->info('A sequence of display modes that the browser will consider before using the display member.') - ->example([['fullscreen', 'minimal-ui']]) - ->scalarPrototype()->end() - ->end() - ->scalarNode('id') - ->info('A string that represents the identity of the web application.') - ->example('?homescreen=1') - ->end() - ->scalarNode('orientation') - ->info('The orientation of the application.') - ->example('portrait-primary') - ->end() - ->scalarNode('dir') - ->info('The direction of the application.') - ->example('rtl') - ->end() - ->scalarNode('lang') - ->info('The language of the application.') - ->example('ar') - ->end() - ->scalarNode('name') - ->info('The name of the application.') - ->example('My awesome application') - ->end() - ->scalarNode('short_name') - ->info('The short name of the application.') - ->example('My awesome application') - ->end() - ->scalarNode('scope') - ->info('The scope of the application.') - ->example('/app/') - ->end() - ->scalarNode('start_url') - ->info('The start URL of the application.') - ->example('https://example.com') - ->end() - ->scalarNode('theme_color') - ->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(string $info): ArrayNodeDefinition - { - $treeBuilder = new TreeBuilder('icons'); - $node = $treeBuilder->getRootNode(); - assert($node instanceof ArrayNodeDefinition); - $node - ->info($info) - ->treatFalseLike([]) - ->treatTrueLike([]) - ->treatNullLike([]) - ->arrayPrototype() - ->beforeNormalization() - ->ifString() - ->then(static fn (string $v): array => [ - 'src' => $v, - ]) - ->end() - ->children() - ->scalarNode('src') - ->isRequired() - ->info('The path to the icon. Can be served by Asset Mapper.') - ->example('icon/logo.svg') - ->end() - ->arrayNode('sizes') - ->beforeNormalization() - ->ifTrue(static fn (mixed $v): bool => is_int($v)) - ->then(static fn (int $v): array => [$v]) - ->end() - ->beforeNormalization() - ->ifTrue(static fn (mixed $v): bool => is_string($v)) - ->then(static function (string $v): array { - if ($v === 'any') { - return [0]; - } - - return [(int) $v]; - }) - ->end() - ->info( - 'The sizes of the icon. 16 means 16x16, 32 means 32x32, etc. 0 means "any" (i.e. it is a vector image).' - ) - ->example([['16', '32']]) - ->integerPrototype()->end() - ->end() - ->scalarNode('format') - ->info('The icon format output.') - ->example(['image/webp', 'image/png']) - ->end() - ->scalarNode('purpose') - ->info('The purpose of the icon.') - ->example(['any', 'maskable', 'monochrome']) - ->end() - ->end() - ->end() - ; - - return $node; + ->end(); } private function setupServiceWorker(ArrayNodeDefinition $node): void { $node->children() ->arrayNode('serviceworker') - ->info('EXPERIMENTAL. Specifies a serviceworker that is registered.') - ->treatFalseLike([]) - ->treatTrueLike([]) - ->treatNullLike([]) + ->canBeEnabled() ->beforeNormalization() - ->ifString() - ->then(static fn (string $v): array => [ - 'src' => $v, - ]) + ->ifString() + ->then(static fn (string $v): array => [ + 'enabled' => true, + 'src' => $v, + ]) ->end() ->children() ->scalarNode('src') @@ -479,7 +109,7 @@ private function setupServiceWorker(ArrayNodeDefinition $node): void ->scalarNode('version') ->defaultValue('7.0.0') ->info( - 'The version of the workbox. When using local files, the version is ignored shall be 7.0.0.' + 'The version of workbox. When using local files, the version shall be "7.0.0."' ) ->end() ->scalarNode('workbox_public_url') @@ -488,9 +118,9 @@ private function setupServiceWorker(ArrayNodeDefinition $node): void ->end() ->scalarNode('workbox_import_placeholder') ->defaultValue('//WORKBOX_IMPORT_PLACEHOLDER') - ->info( - 'The placeholder for the workbox import. Will be replaced by the workbox import.' - ) + ->info( + 'The placeholder for the workbox import. Will be replaced by the workbox import.' + ) ->example('//WORKBOX_IMPORT_PLACEHOLDER') ->end() ->scalarNode('standard_rules_placeholder') @@ -537,7 +167,7 @@ private function setupServiceWorker(ArrayNodeDefinition $node): void ->info('The URLs to warm the cache. The URLs shall be served by the application.') ->arrayPrototype() ->beforeNormalization() - ->ifString() + ->ifString() ->then(static fn (string $v): array => [ 'path' => $v, ]) @@ -553,28 +183,408 @@ private function setupServiceWorker(ArrayNodeDefinition $node): void ->treatTrueLike([]) ->treatNullLike([]) ->prototype('variable')->end() - ->info('The parameters of the action.') + ->info('The parameters of the action.') + ->end() ->end() ->end() ->end() ->end() ->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() - ->scalarNode('scope') + ->end() + ->end() + ->end(); + } + + private function setupShortcuts(): ArrayNodeDefinition + { + $treeBuilder = new TreeBuilder('shortcuts'); + $node = $treeBuilder->getRootNode(); + assert($node instanceof ArrayNodeDefinition); + $node + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->info('The shortcuts of the application.') + ->arrayPrototype() + ->children() + ->scalarNode('name') + ->isRequired() + ->info('The name of the shortcut.') + ->example('Awesome shortcut') + ->end() + ->scalarNode('short_name') + ->info('The short name of the shortcut.') + ->example('Awesome shortcut') + ->end() + ->scalarNode('description') + ->info('The description of the shortcut.') + ->example('Awesome shortcut') + ->end() + ->append($this->getUrlNode('url', 'The URL of the shortcut.')) + ->append($this->getIconsNode('The icons of the shortcut.')) + ->end() + ->end() + ->end(); + + return $node; + } + + private function getFileHandlersNode(): ArrayNodeDefinition + { + $treeBuilder = new TreeBuilder('file_handlers'); + $node = $treeBuilder->getRootNode(); + assert($node instanceof ArrayNodeDefinition); + + $node->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() + ->append($this->getUrlNode('action', 'The action to take.', ['/handle-audio-file'])) + ->arrayNode('accept') + ->requiresAtLeastOneElement() + ->useAttributeAsKey('name') + ->arrayPrototype() + ->scalarPrototype()->end() + ->end() + ->info('The file types that the action will be applied to.') + ->example('image/*') + ->end() + ->end() + ->end() + ->end(); + + return $node; + } + + private function setupSharedTarget(): ArrayNodeDefinition + { + $treeBuilder = new TreeBuilder('share_target'); + $node = $treeBuilder->getRootNode(); + assert($node instanceof ArrayNodeDefinition); + + $node + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->info('The share target of the application.') + ->children() + ->append( + $this->getUrlNode('action', 'The action of the share target.', ['/shared-content-receiver/']) + ) + ->scalarNode('method') + ->info('The method of the share target.') + ->example('GET') + ->end() + ->scalarNode('enctype') + ->info('The enctype of the share target. Ignored if method is GET.') + ->example('multipart/form-data') + ->end() + ->arrayNode('params') + ->isRequired() + ->info('The parameters of the share target.') + ->children() + ->scalarNode('title') + ->info('The title of the share target.') + ->example('name') + ->end() + ->scalarNode('text') + ->info('The text of the share target.') + ->example('description') + ->end() + ->scalarNode('url') + ->info('The URL of the share target.') + ->example('link') + ->end() + ->arrayNode('files') + ->info('The files of the share target.') + ->scalarPrototype()->end() + ->end() + ->end() + ->end() + ->end() + ->end(); + + return $node; + } + + private function getProtocolHandlersNode(): ArrayNodeDefinition + { + $treeBuilder = new TreeBuilder('protocol_handlers'); + $node = $treeBuilder->getRootNode(); + assert($node instanceof ArrayNodeDefinition); + + $node->info('The protocol handlers of the application.') + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->arrayPrototype() + ->children() + ->scalarNode('protocol') + ->isRequired() + ->info('The protocol of the handler.') + ->example('web+jngl') + ->end() + ->append($this->getUrlNode('url', 'The URL of the handler.')) + ->end() + ->end() + ->end(); + + return $node; + } + + private function getLaunchHandlerNode(): ArrayNodeDefinition + { + $treeBuilder = new TreeBuilder('launch_handler'); + $node = $treeBuilder->getRootNode(); + assert($node instanceof ArrayNodeDefinition); + + $node->info('The launch handler of the application.') + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->children() + ->arrayNode('client_mode') + ->info('The client mode of the application.') + ->example(['focus-existing', 'auto']) + ->scalarPrototype()->end() + ->beforeNormalization() + ->castToArray() + ->end() + ->end() + ->end() + ->end(); + + return $node; + } + + private function setupRelatedApplications(): ArrayNodeDefinition + { + $treeBuilder = new TreeBuilder('related_applications'); + $node = $treeBuilder->getRootNode(); + assert($node instanceof ArrayNodeDefinition); + $node + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->info('The related applications of the application.') + ->arrayPrototype() + ->children() + ->scalarNode('platform') + ->isRequired() + ->info('The platform of the application.') + ->example('play') + ->end() + ->append( + $this->getUrlNode('url', 'The URL of the application.', [ + 'https://play.google.com/store/apps/details?id=com.example.app1', + ]) + ) + ->scalarNode('id') + ->info('The ID of the application.') + ->example('com.example.app1') + ->end() + ->end() + ->end() + ->end(); + + return $node; + } + + private function setupManifest(ArrayNodeDefinition $node): void + { + $node->children() + ->arrayNode('manifest') + ->canBeEnabled() + ->children() + ->scalarNode('public_url') + ->defaultValue('/site.webmanifest') ->cannotBeEmpty() - ->defaultValue('/') - ->info('The scope of the service worker.') + ->info('The public URL of the manifest file.') + ->example('/site.manifest') + ->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.' + ) + ->example('red') + ->end() + ->arrayNode('categories') + ->info('The categories of the application.') + ->example([['news', 'sports', 'lifestyle']]) + ->scalarPrototype()->end() + ->end() + ->scalarNode('description') + ->info('The description of the application.') + ->example('My awesome application') + ->end() + ->scalarNode('display') + ->info('The display mode of the application.') + ->example('standalone') + ->end() + ->arrayNode('display_override') + ->info( + 'A sequence of display modes that the browser will consider before using the display member.' + ) + ->example([['fullscreen', 'minimal-ui']]) + ->scalarPrototype()->end() + ->end() + ->scalarNode('id') + ->info('A string that represents the identity of the web application.') + ->example('?homescreen=1') + ->end() + ->scalarNode('orientation') + ->info('The orientation of the application.') + ->example('portrait-primary') + ->end() + ->scalarNode('dir') + ->info('The direction of the application.') + ->example('rtl') + ->end() + ->scalarNode('lang') + ->info('The language of the application.') + ->example('ar') + ->end() + ->scalarNode('name') + ->info('The name of the application.') + ->example('My awesome application') + ->end() + ->scalarNode('short_name') + ->info('The short name of the application.') + ->example('My awesome application') + ->end() + ->scalarNode('scope') + ->info('The scope of the application.') ->example('/app/') ->end() - ->booleanNode('use_cache') - ->defaultTrue() - ->info('Whether the service worker should use the cache.') + ->scalarNode('start_url') + ->info('The start URL of the application.') + ->example('https://example.com') + ->end() + ->scalarNode('theme_color') + ->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() + ->append($this->getIconsNode('The icons of the application.')) + ->append($this->getScreenshotsNode('The screenshots of the application.')) + ->append($this->getFileHandlersNode()) + ->append($this->getLaunchHandlerNode()) + ->append($this->getProtocolHandlersNode()) + ->booleanNode('prefer_related_applications') + ->info('The prefer related native applications of the application.') + ->end() + ->append($this->setupRelatedApplications()) + ->append($this->setupShortcuts()) + ->append($this->setupSharedTarget()) + ->append($this->setupWidgets()) ->end() ->end() ->end(); } + private function getIconsNode(string $info): ArrayNodeDefinition + { + $treeBuilder = new TreeBuilder('icons'); + $node = $treeBuilder->getRootNode(); + assert($node instanceof ArrayNodeDefinition); + $node->info($info) + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->arrayPrototype() + ->beforeNormalization() + ->ifString() + ->then(static fn (string $v): array => [ + 'src' => $v, + ]) + ->end() + ->children() + ->scalarNode('src') + ->isRequired() + ->info('The path to the icon. Can be served by Asset Mapper.') + ->example('icon/logo.svg') + ->end() + ->arrayNode('sizes') + ->beforeNormalization() + ->ifTrue(static fn (mixed $v): bool => is_int($v)) + ->then(static fn (int $v): array => [$v]) + ->end() + ->beforeNormalization() + ->ifTrue(static fn (mixed $v): bool => is_string($v)) + ->then(static function (string $v): array { + if ($v === 'any') { + return [0]; + } + + return [(int) $v]; + }) + ->end() + ->info( + 'The sizes of the icon. 16 means 16x16, 32 means 32x32, etc. 0 means "any" (i.e. it is a vector image).' + ) + ->example([['16', '32']]) + ->integerPrototype()->end() + ->end() + ->scalarNode('format') + ->info('The icon format output.') + ->example(['image/webp', 'image/png']) + ->end() + ->scalarNode('purpose') + ->info('The purpose of the icon.') + ->example(['any', 'maskable', 'monochrome']) + ->end() + ->end() + ->end() + ; + + return $node; + } + private function getScreenshotsNode(string $info): ArrayNodeDefinition { $treeBuilder = new TreeBuilder('screenshots'); @@ -629,77 +639,80 @@ private function getScreenshotsNode(string $info): ArrayNodeDefinition return $node; } - private function setupWidgets(ArrayNodeDefinition $node): void + private function setupWidgets(): ArrayNodeDefinition { - $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.' - ) + $treeBuilder = new TreeBuilder('widgets'); + $node = $treeBuilder->getRootNode(); + assert($node instanceof ArrayNodeDefinition); + $node + ->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.' ) - ->append( - $this->getScreenshotsNode('The screenshots of the widget')->requiresAtLeastOneElement() + ->end() + ->append( + $this->getUrlNode( + 'ms_ac_template', + 'The URL of the custom Adaptive Cards template to use to display the widget in the operating system widgets dashboard.' ) - ->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() - ->append( - $this->getUrlNode( - 'ms_ac_template', - 'The URL of the custom Adaptive Cards template to use to display the widget in the operating system widgets dashboard.' - ) + ) + ->append( + $this->getUrlNode( + 'data', + 'The URL where the data to fill the template with can be found. If present, this URL is required to return valid JSON.' ) - ->append( - $this->getUrlNode( - 'data', - 'The URL where the data to fill the template with can be found. If present, this URL is required to return valid JSON.' - ) + ) + ->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.' ) - ->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(); + + return $node; } /** diff --git a/src/DependencyInjection/SpomkyLabsPwaExtension.php b/src/DependencyInjection/SpomkyLabsPwaExtension.php index 6e7dbdc..d298366 100644 --- a/src/DependencyInjection/SpomkyLabsPwaExtension.php +++ b/src/DependencyInjection/SpomkyLabsPwaExtension.php @@ -37,16 +37,22 @@ 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']); - $container->setParameter('spomky_labs_pwa.manifest_public_url', $config['manifest_public_url']); - $container->setParameter('spomky_labs_pwa.sw_public_url', $config['serviceworker']['dest'] ?? null); - - unset( - $config['image_processor'], - $config['web_client'], - $config['path_type_reference'], - $config['manifest_public_url'], - ); - $container->setParameter('spomky_labs_pwa.config', $config); + $serviceWorkerConfig = $config['serviceworker']; + $manifestConfig = $config['manifest']; + if ($serviceWorkerConfig['enabled'] === true && $manifestConfig['enabled'] === true) { + $manifestConfig['serviceworker'] = $serviceWorkerConfig; + } + + /*** Manifest ***/ + $container->setParameter('spomky_labs_pwa.manifest.enabled', $config['manifest']['enabled']); + $container->setParameter('spomky_labs_pwa.manifest.public_url', $config['manifest']['public_url'] ?? null); + $container->setParameter('spomky_labs_pwa.manifest.config', $manifestConfig); + + /*** Service Worker ***/ + $container->setParameter('spomky_labs_pwa.sw.enabled', $config['serviceworker']['enabled']); + $container->setParameter('spomky_labs_pwa.sw.public_url', $config['serviceworker']['dest'] ?? null); + $container->setParameter('spomky_labs_pwa.sw.config', $serviceWorkerConfig); + if (! in_array($container->getParameter('kernel.environment'), ['dev', 'test'], true)) { $container->removeDefinition(PwaDevServerSubscriber::class); } diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index fca6870..033f018 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -7,10 +7,12 @@ use SpomkyLabs\PwaBundle\Command\CreateScreenshotCommand; use SpomkyLabs\PwaBundle\Command\CreateServiceWorkerCommand; use SpomkyLabs\PwaBundle\Dto\Manifest; +use SpomkyLabs\PwaBundle\Dto\ServiceWorker; use SpomkyLabs\PwaBundle\ImageProcessor\GDImageProcessor; use SpomkyLabs\PwaBundle\ImageProcessor\ImagickImageProcessor; use SpomkyLabs\PwaBundle\Service\ManifestBuilder; use SpomkyLabs\PwaBundle\Service\ServiceWorkerBuilder; +use SpomkyLabs\PwaBundle\Service\ServiceWorkerCompiler; use SpomkyLabs\PwaBundle\Subscriber\AssetsCompileEventListener; use SpomkyLabs\PwaBundle\Subscriber\PwaDevServerSubscriber; use SpomkyLabs\PwaBundle\Subscriber\ServiceWorkerCompileEventListener; @@ -20,6 +22,7 @@ use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\Mime\MimeTypes; use Symfony\Component\Panther\Client; +use function Symfony\Component\DependencyInjection\Loader\Configurator\param; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; return static function (ContainerConfigurator $container): void { @@ -30,15 +33,29 @@ ->autowire() ; + /*** Manifest ***/ $container->set(ManifestBuilder::class) ->args([ - '$config' => '%spomky_labs_pwa.config%', + '$config' => param('spomky_labs_pwa.manifest.config'), ]) ; $container->set(Manifest::class) - ->factory([service(ManifestBuilder::class), 'createManifest']) + ->factory([service(ManifestBuilder::class), 'create']) ; + /*** Service Worker ***/ + $container->set(ServiceWorkerBuilder::class) + ->args([ + '$config' => param('spomky_labs_pwa.sw.config'), + ]) + ; + $container->set(ServiceWorker::class) + ->factory([service(ServiceWorkerBuilder::class), 'create']) + ; + $container->set(ServiceWorkerCompiler::class) + ; + + /*** Commands ***/ $container->set(CreateServiceWorkerCommand::class); if (class_exists(Client::class) && class_exists(WebDriverDimension::class) && class_exists(MimeTypes::class)) { $container->set(CreateScreenshotCommand::class); @@ -47,12 +64,14 @@ $container->set(CreateIconsCommand::class); } + /*** Normalizers ***/ $container->load('SpomkyLabs\\PwaBundle\\Normalizer\\', '../../Normalizer/*') ->tag('serializer.normalizer', [ 'priority' => 1024, ]) ; + /*** Image Processors ***/ if (extension_loaded('imagick')) { $container ->set(ImagickImageProcessor::class) @@ -66,10 +85,11 @@ ; } + /*** Event Listeners and Subscribers ***/ $container->set(WorkboxCompileEventListener::class); $container->set(AssetsCompileEventListener::class); $container->set(ServiceWorkerCompileEventListener::class); - $container->set(ServiceWorkerBuilder::class); + $container->set(ServiceWorkerCompiler::class); $container->set(PwaDevServerSubscriber::class) ->args([ diff --git a/src/Service/ManifestBuilder.php b/src/Service/ManifestBuilder.php index 10b11ae..f478471 100644 --- a/src/Service/ManifestBuilder.php +++ b/src/Service/ManifestBuilder.php @@ -21,7 +21,7 @@ public function __construct( ) { } - public function createManifest(): Manifest + public function create(): Manifest { if ($this->manifest === null) { $result = $this->denormalizer->denormalize($this->config, Manifest::class); diff --git a/src/Service/ServiceWorkerBuilder.php b/src/Service/ServiceWorkerBuilder.php index 2ac6a82..d4cc8d9 100644 --- a/src/Service/ServiceWorkerBuilder.php +++ b/src/Service/ServiceWorkerBuilder.php @@ -4,255 +4,31 @@ namespace SpomkyLabs\PwaBundle\Service; -use SpomkyLabs\PwaBundle\Dto\Manifest; -use SpomkyLabs\PwaBundle\Dto\Workbox; -use Symfony\Component\AssetMapper\AssetMapperInterface; -use Symfony\Component\Serializer\Encoder\JsonEncode; -use Symfony\Component\Serializer\SerializerInterface; +use SpomkyLabs\PwaBundle\Dto\ServiceWorker; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use function assert; -use function count; -use function is_string; -use const JSON_PRETTY_PRINT; -use const JSON_THROW_ON_ERROR; -use const JSON_UNESCAPED_SLASHES; -use const JSON_UNESCAPED_UNICODE; -use const PHP_EOL; -final readonly class ServiceWorkerBuilder +final class ServiceWorkerBuilder { + private null|ServiceWorker $serviceWorker = null; + + /** + * @param array $config + */ public function __construct( - private SerializerInterface $serializer, - private Manifest $manifest, - private AssetMapperInterface $assetMapper, + private readonly DenormalizerInterface $denormalizer, + private readonly array $config, ) { } - public function build(): ?string - { - $serviceWorker = $this->manifest->serviceWorker; - if ($serviceWorker === null) { - return null; - } - - if (! str_starts_with($serviceWorker->src, '/')) { - $asset = $this->assetMapper->getAsset($serviceWorker->src); - assert($asset !== null, 'Unable to find service worker source asset'); - $body = $asset->content ?? file_get_contents($asset->sourcePath); - } else { - $body = file_get_contents($serviceWorker->src); - } - assert(is_string($body), 'Unable to find service worker source content'); - $workbox = $serviceWorker->workbox; - if ($workbox->enabled === true) { - $body = $this->processWorkbox($workbox, $body); - } - - return $body; - } - - private function processWorkbox(Workbox $workbox, string $body): string + public function create(): ServiceWorker { - $body = $this->processWorkboxImport($workbox, $body); - $body = $this->processStandardRules($workbox, $body); - $body = $this->processPrecachedAssets($workbox, $body); - $body = $this->processWarmCacheUrls($workbox, $body); - $body = $this->processWidgets($workbox, $body); - - return $this->processOfflineFallback($workbox, $body); - } - - private function processStandardRules(Workbox $workbox, string $body): string - { - if (! str_contains($body, $workbox->standardRulesPlaceholder)) { - return $body; - } - - $declaration = << request.destination === 'style' || request.destination === 'script' || request.destination === 'worker'; -workbox.routing.registerRoute( - matchCallback, - new workbox.strategies.CacheFirst({ - cacheName: 'static-resources', - plugins: [ - new workbox.cacheableResponse.CacheableResponsePlugin({ - statuses: [0, 200], - }), - ], - }) -); -STANDARD_RULE_STRATEGY; - - return str_replace($workbox->standardRulesPlaceholder, trim($declaration), $body); - } - - private function processPrecachedAssets(Workbox $workbox, string $body): string - { - if (! str_contains($body, $workbox->precachingPlaceholder)) { - return $body; - } - $result = []; - foreach ($this->assetMapper->allAssets() as $asset) { - $result[] = [ - 'url' => $asset->publicPath, - 'revision' => $asset->digest, - ]; - } - $assets = $this->serializer->serialize($result, 'json', [ - JsonEncode::OPTIONS => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, - ]); - - $declaration = <<precachingPlaceholder, trim($declaration), $body); - } - - private function processWarmCacheUrls(Workbox $workbox, string $body): string - { - if (! str_contains($body, $workbox->warmCachePlaceholder)) { - return $body; - } - $urls = $workbox->warmCacheUrls; - if (count($urls) === 0) { - return $body; - } - - $routes = $this->serializer->serialize($urls, 'json', [ - JsonEncode::OPTIONS => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, - ]); - - $declaration = <<warmCachePlaceholder, trim($declaration), $body); - } - - private function processOfflineFallback(Workbox $workbox, string $body): string - { - if (! str_contains($body, $workbox->offlineFallbackPlaceholder) || $workbox->offlineFallback === null) { - return $body; - } - - $url = $this->serializer->serialize($workbox->offlineFallback, 'json', [ - JsonEncode::OPTIONS => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, - ]); - - $declaration = sprintf('%sworkbox.recipes.offlineFallback({ pageFallback: %s });', PHP_EOL, $url); - - return str_replace($workbox->offlineFallbackPlaceholder, trim($declaration), $body); - } - - private function processWidgets(Workbox $workbox, string $body): string - { - if (! str_contains($body, $workbox->widgetsPlaceholder)) { - return $body; - } - $tags = []; - foreach ($this->manifest->widgets as $widget) { - if ($widget->tag !== null) { - $tags[] = $widget->tag; - } - } - if (count($tags) === 0) { - return $body; - } - $data = $this->serializer->serialize($tags, 'json', [ - JsonEncode::OPTIONS => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, - ]); - - $declaration = << { - event.waitUntil(renderWidget(event.widget)); -}); -async function renderWidget(widget) { - const templateUrl = widget.definition.msAcTemplate; - const dataUrl = widget.definition.data; - const template = await (await fetch(templateUrl)).text(); - const data = await (await fetch(dataUrl)).text(); - await self.widgets.updateByTag(widget.definition.tag, {template, data}); -} - -self.addEventListener("widgetinstall", event => { - event.waitUntil(onWidgetInstall(event.widget)); -}); -async function onWidgetInstall(widget) { - const tags = await self.registration.periodicSync.getTags(); - if (!tags.includes(widget.definition.tag)) { - await self.registration.periodicSync.register(widget.definition.tag, { - minInterval: widget.definition.update - }); - } - await updateWidget(widget); -} - -self.addEventListener("widgetuninstall", event => { - event.waitUntil(onWidgetUninstall(event.widget)); -}); - -async function onWidgetUninstall(widget) { - if (widget.instances.length === 1 && "update" in widget.definition) { - await self.registration.periodicSync.unregister(widget.definition.tag); - } -} -self.addEventListener("periodicsync", async event => { - const widget = await self.widgets.getByTag(event.tag); - if (widget && "update" in widget.definition) { - event.waitUntil(renderWidget(widget)); - } -}); - -self.addEventListener("activate", event => { - event.waitUntil(updateWidgets()); -}); - -async function updateWidgets() { - const tags = {$data}; - if(!self.widgets || tags.length === 0) return; - for (const tag of tags) { - const widget = await self.widgets.getByTag(tag); - if (!widget) { - continue; - } - const template = await (await fetch(widget.definition.msAcTemplate)).text(); - const data = await (await fetch(widget.definition.data)).text(); - await self.widgets.updateByTag(widget.definition.tag, {template, data}); - } -} -OFFLINE_FALLBACK_STRATEGY; - - return str_replace($workbox->widgetsPlaceholder, trim($declaration), $body); - } - - private function processWorkboxImport(Workbox $workbox, string $body): string - { - if (! str_contains($body, $workbox->workboxImportPlaceholder)) { - return $body; - } - if ($workbox->useCDN === true) { - $declaration = <<version}/workbox-sw.js' -); -IMPORT_CDN_STRATEGY; - } else { - $publicUrl = '/' . trim($workbox->workboxPublicUrl, '/'); - $declaration = <<serviceWorker === null) { + $result = $this->denormalizer->denormalize($this->config, ServiceWorker::class); + assert($result instanceof ServiceWorker); + $this->serviceWorker = $result; } - return str_replace($workbox->workboxImportPlaceholder, trim($declaration), $body); + return $this->serviceWorker; } } diff --git a/src/Service/ServiceWorkerCompiler.php b/src/Service/ServiceWorkerCompiler.php new file mode 100644 index 0000000..786eb91 --- /dev/null +++ b/src/Service/ServiceWorkerCompiler.php @@ -0,0 +1,268 @@ +serviceWorkerEnabled === false) { + return null; + } + $serviceWorker = $this->serviceWorker; + if ($serviceWorker === null) { + return null; + } + + if (! str_starts_with($serviceWorker->src, '/')) { + $asset = $this->assetMapper->getAsset($serviceWorker->src); + assert($asset !== null, 'Unable to find service worker source asset'); + $body = $asset->content ?? file_get_contents($asset->sourcePath); + } else { + $body = file_get_contents($serviceWorker->src); + } + assert(is_string($body), 'Unable to find service worker source content'); + $workbox = $serviceWorker->workbox; + if ($workbox->enabled === true) { + $body = $this->processWorkbox($workbox, $body); + } + + return $body; + } + + private function processWorkbox(Workbox $workbox, string $body): string + { + $body = $this->processWorkboxImport($workbox, $body); + $body = $this->processStandardRules($workbox, $body); + $body = $this->processPrecachedAssets($workbox, $body); + $body = $this->processWarmCacheUrls($workbox, $body); + $body = $this->processWidgets($workbox, $body); + + return $this->processOfflineFallback($workbox, $body); + } + + private function processStandardRules(Workbox $workbox, string $body): string + { + if (! str_contains($body, $workbox->standardRulesPlaceholder)) { + return $body; + } + + $declaration = << request.destination === 'style' || request.destination === 'script' || request.destination === 'worker'; +workbox.routing.registerRoute( + matchCallback, + new workbox.strategies.CacheFirst({ + cacheName: 'static-resources', + plugins: [ + new workbox.cacheableResponse.CacheableResponsePlugin({ + statuses: [0, 200], + }), + ], + }) +); +STANDARD_RULE_STRATEGY; + + return str_replace($workbox->standardRulesPlaceholder, trim($declaration), $body); + } + + private function processPrecachedAssets(Workbox $workbox, string $body): string + { + if (! str_contains($body, $workbox->precachingPlaceholder)) { + return $body; + } + $result = []; + foreach ($this->assetMapper->allAssets() as $asset) { + $result[] = [ + 'url' => $asset->publicPath, + 'revision' => $asset->digest, + ]; + } + $assets = $this->serializer->serialize($result, 'json', [ + JsonEncode::OPTIONS => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ]); + + $declaration = <<precachingPlaceholder, trim($declaration), $body); + } + + private function processWarmCacheUrls(Workbox $workbox, string $body): string + { + if (! str_contains($body, $workbox->warmCachePlaceholder)) { + return $body; + } + $urls = $workbox->warmCacheUrls; + if (count($urls) === 0) { + return $body; + } + + $routes = $this->serializer->serialize($urls, 'json', [ + JsonEncode::OPTIONS => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ]); + + $declaration = <<warmCachePlaceholder, trim($declaration), $body); + } + + private function processOfflineFallback(Workbox $workbox, string $body): string + { + if (! str_contains($body, $workbox->offlineFallbackPlaceholder) || $workbox->offlineFallback === null) { + return $body; + } + + $url = $this->serializer->serialize($workbox->offlineFallback, 'json', [ + JsonEncode::OPTIONS => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ]); + + $declaration = <<offlineFallbackPlaceholder, trim($declaration), $body); + } + + private function processWidgets(Workbox $workbox, string $body): string + { + if (! str_contains($body, $workbox->widgetsPlaceholder)) { + return $body; + } + $tags = []; + foreach ($this->manifest->widgets as $widget) { + if ($widget->tag !== null) { + $tags[] = $widget->tag; + } + } + if (count($tags) === 0) { + return $body; + } + $data = $this->serializer->serialize($tags, 'json', [ + JsonEncode::OPTIONS => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ]); + + $declaration = << { + event.waitUntil(renderWidget(event.widget)); +}); +async function renderWidget(widget) { + const templateUrl = widget.definition.msAcTemplate; + const dataUrl = widget.definition.data; + const template = await (await fetch(templateUrl)).text(); + const data = await (await fetch(dataUrl)).text(); + await self.widgets.updateByTag(widget.definition.tag, {template, data}); +} + +self.addEventListener("widgetinstall", event => { + event.waitUntil(onWidgetInstall(event.widget)); +}); +async function onWidgetInstall(widget) { + const tags = await self.registration.periodicSync.getTags(); + if (!tags.includes(widget.definition.tag)) { + await self.registration.periodicSync.register(widget.definition.tag, { + minInterval: widget.definition.update + }); + } + await updateWidget(widget); +} + +self.addEventListener("widgetuninstall", event => { + event.waitUntil(onWidgetUninstall(event.widget)); +}); + +async function onWidgetUninstall(widget) { + if (widget.instances.length === 1 && "update" in widget.definition) { + await self.registration.periodicSync.unregister(widget.definition.tag); + } +} +self.addEventListener("periodicsync", async event => { + const widget = await self.widgets.getByTag(event.tag); + if (widget && "update" in widget.definition) { + event.waitUntil(renderWidget(widget)); + } +}); + +self.addEventListener("activate", event => { + event.waitUntil(updateWidgets()); +}); + +async function updateWidgets() { + const tags = {$data}; + if(!self.widgets || tags.length === 0) return; + for (const tag of tags) { + const widget = await self.widgets.getByTag(tag); + if (!widget) { + continue; + } + const template = await (await fetch(widget.definition.msAcTemplate)).text(); + const data = await (await fetch(widget.definition.data)).text(); + await self.widgets.updateByTag(widget.definition.tag, {template, data}); + } +} +OFFLINE_FALLBACK_STRATEGY; + + return str_replace($workbox->widgetsPlaceholder, trim($declaration), $body); + } + + private function processWorkboxImport(Workbox $workbox, string $body): string + { + if (! str_contains($body, $workbox->workboxImportPlaceholder)) { + return $body; + } + if ($workbox->useCDN === true) { + $declaration = <<version}/workbox-sw.js' +); +IMPORT_CDN_STRATEGY; + } else { + $publicUrl = '/' . trim($workbox->workboxPublicUrl, '/'); + $declaration = <<workboxImportPlaceholder, trim($declaration), $body); + } +} diff --git a/src/Subscriber/AssetsCompileEventListener.php b/src/Subscriber/AssetsCompileEventListener.php index f442ac3..555a911 100644 --- a/src/Subscriber/AssetsCompileEventListener.php +++ b/src/Subscriber/AssetsCompileEventListener.php @@ -25,7 +25,9 @@ public function __construct( private SerializerInterface $serializer, private Manifest $manifest, - #[Autowire('%spomky_labs_pwa.manifest_public_url%')] + #[Autowire('%spomky_labs_pwa.manifest.enabled%')] + private bool $manifestEnabled, + #[Autowire('%spomky_labs_pwa.manifest.public_url%')] string $manifestPublicUrl, #[Autowire('@asset_mapper.local_public_assets_filesystem')] private PublicAssetsFilesystemInterface $assetsFilesystem, @@ -35,6 +37,9 @@ public function __construct( public function __invoke(PreAssetsCompileEvent $event): void { + if (! $this->manifestEnabled) { + return; + } $data = $this->serializer->serialize($this->manifest, 'json', [ AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true, AbstractObjectNormalizer::SKIP_NULL_VALUES => true, diff --git a/src/Subscriber/PwaDevServerSubscriber.php b/src/Subscriber/PwaDevServerSubscriber.php index 57162dc..6870007 100644 --- a/src/Subscriber/PwaDevServerSubscriber.php +++ b/src/Subscriber/PwaDevServerSubscriber.php @@ -5,7 +5,8 @@ namespace SpomkyLabs\PwaBundle\Subscriber; use SpomkyLabs\PwaBundle\Dto\Manifest; -use SpomkyLabs\PwaBundle\Service\ServiceWorkerBuilder; +use SpomkyLabs\PwaBundle\Dto\ServiceWorker; +use SpomkyLabs\PwaBundle\Service\ServiceWorkerCompiler; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Response; @@ -38,23 +39,28 @@ private null|string $workboxVersion; public function __construct( - private readonly FileLocator $fileLocator, - private ServiceWorkerBuilder $serviceWorkerBuilder, + private FileLocator $fileLocator, + private ServiceWorkerCompiler $serviceWorkerBuilder, private SerializerInterface $serializer, private Manifest $manifest, - #[Autowire('%spomky_labs_pwa.manifest_public_url%')] + ServiceWorker $serviceWorker, + #[Autowire('%spomky_labs_pwa.manifest.enabled%')] + private bool $manifestEnabled, + #[Autowire('%spomky_labs_pwa.sw.enabled%')] + private bool $serviceWorkerEnabled, + #[Autowire('%spomky_labs_pwa.manifest.public_url%')] string $manifestPublicUrl, private null|Profiler $profiler, ) { $this->manifestPublicUrl = '/' . trim($manifestPublicUrl, '/'); - $serviceWorkerPublicUrl = $manifest->serviceWorker?->dest; + $serviceWorkerPublicUrl = $serviceWorker?->dest; $this->serviceWorkerPublicUrl = $serviceWorkerPublicUrl === null ? null : '/' . trim( $serviceWorkerPublicUrl, '/' ); - if ($manifest->serviceWorker?->workbox->enabled === true) { - $this->workboxVersion = $manifest->serviceWorker->workbox->version; - $workboxPublicUrl = $manifest->serviceWorker->workbox->workboxPublicUrl; + if ($serviceWorker?->workbox->enabled === true) { + $this->workboxVersion = $serviceWorker->workbox->version; + $workboxPublicUrl = $serviceWorker->workbox->workboxPublicUrl; $this->workboxPublicUrl = '/' . trim($workboxPublicUrl, '/'); } else { $this->workboxVersion = null; @@ -70,14 +76,15 @@ public function onKernelRequest(RequestEvent $event): void $pathInfo = $event->getRequest() ->getPathInfo(); + switch (true) { - case $pathInfo === $this->manifestPublicUrl : + case $this->manifestEnabled === true && $pathInfo === $this->manifestPublicUrl : $this->serveManifest($event); break; - case $pathInfo === $this->serviceWorkerPublicUrl : + case $this->serviceWorkerEnabled === true && $pathInfo === $this->serviceWorkerPublicUrl : $this->serveServiceWorker($event); break; - case $this->workboxVersion !== null && $this->workboxPublicUrl !== null && str_starts_with( + case $this->serviceWorkerEnabled === true && $this->workboxVersion !== null && $this->workboxPublicUrl !== null && str_starts_with( $pathInfo, $this->workboxPublicUrl ) : @@ -126,7 +133,7 @@ private function serveManifest(RequestEvent $event): void private function serveServiceWorker(RequestEvent $event): void { - $data = $this->serviceWorkerBuilder->build(); + $data = $this->serviceWorkerBuilder->compile(); if ($data === null) { return; } diff --git a/src/Subscriber/ServiceWorkerCompileEventListener.php b/src/Subscriber/ServiceWorkerCompileEventListener.php index 5d5d92a..68721aa 100644 --- a/src/Subscriber/ServiceWorkerCompileEventListener.php +++ b/src/Subscriber/ServiceWorkerCompileEventListener.php @@ -4,7 +4,7 @@ namespace SpomkyLabs\PwaBundle\Subscriber; -use SpomkyLabs\PwaBundle\Service\ServiceWorkerBuilder; +use SpomkyLabs\PwaBundle\Service\ServiceWorkerCompiler; use Symfony\Component\AssetMapper\Event\PreAssetsCompileEvent; use Symfony\Component\AssetMapper\Path\PublicAssetsFilesystemInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -16,8 +16,10 @@ private ?string $serviceWorkerPublicUrl; public function __construct( - private ServiceWorkerBuilder $serviceWorkerBuilder, - #[Autowire('%spomky_labs_pwa.sw_public_url%')] + private ServiceWorkerCompiler $serviceWorkerBuilder, + #[Autowire('%spomky_labs_pwa.sw.enabled%')] + private bool $serviceWorkerEnabled, + #[Autowire('%spomky_labs_pwa.sw.public_url%')] ?string $serviceWorkerPublicUrl, #[Autowire('@asset_mapper.local_public_assets_filesystem')] private PublicAssetsFilesystemInterface $assetsFilesystem, @@ -30,7 +32,10 @@ public function __construct( public function __invoke(PreAssetsCompileEvent $event): void { - $data = $this->serviceWorkerBuilder->build(); + if (! $this->serviceWorkerEnabled) { + return; + } + $data = $this->serviceWorkerBuilder->compile(); if ($data === null || $this->serviceWorkerPublicUrl === null) { return; } diff --git a/src/Subscriber/WorkboxCompileEventListener.php b/src/Subscriber/WorkboxCompileEventListener.php index c795bf9..af9fe8e 100644 --- a/src/Subscriber/WorkboxCompileEventListener.php +++ b/src/Subscriber/WorkboxCompileEventListener.php @@ -19,6 +19,8 @@ final readonly class WorkboxCompileEventListener { public function __construct( + #[Autowire('%spomky_labs_pwa.sw.enabled%')] + private bool $serviceWorkerEnabled, #[Autowire('@asset_mapper.local_public_assets_filesystem')] private PublicAssetsFilesystemInterface $assetsFilesystem, private Manifest $manifest, @@ -28,6 +30,9 @@ public function __construct( public function __invoke(PreAssetsCompileEvent $event): void { + if (! $this->serviceWorkerEnabled) { + return; + } $serviceWorker = $this->manifest->serviceWorker; if ($serviceWorker === null || $serviceWorker->workbox->enabled !== true || $serviceWorker->workbox->useCDN === true) { return; diff --git a/src/Twig/PwaRuntime.php b/src/Twig/PwaRuntime.php index 1a09fd7..07e2b87 100644 --- a/src/Twig/PwaRuntime.php +++ b/src/Twig/PwaRuntime.php @@ -25,7 +25,7 @@ public function __construct( private ImportMapConfigReader $importMapConfigReader, private AssetMapperInterface $assetMapper, private Manifest $manifest, - #[Autowire('%spomky_labs_pwa.manifest_public_url%')] + #[Autowire('%spomky_labs_pwa.manifest.public_url%')] string $manifestPublicUrl, ) { $this->manifestPublicUrl = '/' . trim($manifestPublicUrl, '/'); diff --git a/tests/Controller/DummyController.php b/tests/Controller/DummyController.php index d79b081..032af58 100644 --- a/tests/Controller/DummyController.php +++ b/tests/Controller/DummyController.php @@ -12,6 +12,16 @@ */ final class DummyController extends AbstractController { + #[Route('/privacy-policy', name: 'privacy_policy')] + public function privacyPolicy(string $param1): void + { + } + + #[Route('/terms-of-service', name: 'terms_of_service')] + public function tos(string $param1): void + { + } + #[Route('/audio-file-handler/{param1}', name: 'audio_file_handler')] public function dummy1(string $param1): void { diff --git a/tests/Functional/DevServerTest.php b/tests/Functional/DevServerTest.php index af86ad5..c67b53e 100644 --- a/tests/Functional/DevServerTest.php +++ b/tests/Functional/DevServerTest.php @@ -38,5 +38,6 @@ public static function theServiceWorkerIsServed(): void // Then static::assertResponseIsSuccessful(); static::assertResponseHeaderSame('Content-Type', 'application/javascript'); + dump($client->getResponse()->getContent()); } } diff --git a/tests/config.php b/tests/config.php index 400a589..2c406cf 100644 --- a/tests/config.php +++ b/tests/config.php @@ -40,182 +40,190 @@ ]); $container->extension('pwa', [ 'image_processor' => DummyImageProcessor::class, - 'background_color' => 'red', - 'categories' => ['pwa.categories.0', 'pwa.categories.1', 'pwa.categories.2'], - 'description' => 'pwa.description', - 'display' => 'standalone', - 'display_override' => ['fullscreen', 'minimal-ui'], - 'file_handlers' => [ - [ - 'action' => [ - 'path' => 'audio_file_handler', - 'params' => [ - 'param1' => 'audio', + 'manifest' => [ + 'enabled' => true, + 'background_color' => 'red', + 'categories' => ['pwa.categories.0', 'pwa.categories.1', 'pwa.categories.2'], + 'description' => 'pwa.description', + 'display' => 'standalone', + 'display_override' => ['fullscreen', 'minimal-ui'], + 'file_handlers' => [ + [ + 'action' => [ + 'path' => 'audio_file_handler', + 'params' => [ + 'param1' => 'audio', + ], + ], + 'accept' => [ + 'audio/wav' => ['.wav'], + 'audio/x-wav' => ['.wav'], + 'audio/mpeg' => ['.mp3'], + 'audio/mp4' => ['.mp4'], + 'audio/aac' => ['.adts'], + 'audio/ogg' => ['.ogg'], + 'application/ogg' => ['.ogg'], + 'audio/webm' => ['.webm'], + 'audio/flac' => ['.flac'], + 'audio/mid' => ['.rmi', '.mid'], ], ], - 'accept' => [ - 'audio/wav' => ['.wav'], - 'audio/x-wav' => ['.wav'], - 'audio/mpeg' => ['.mp3'], - 'audio/mp4' => ['.mp4'], - 'audio/aac' => ['.adts'], - 'audio/ogg' => ['.ogg'], - 'application/ogg' => ['.ogg'], - 'audio/webm' => ['.webm'], - 'audio/flac' => ['.flac'], - 'audio/mid' => ['.rmi', '.mid'], - ], - ], - ], - 'icons' => [ - [ - 'src' => 'pwa/1920x1920.svg', - 'sizes' => [48, 72, 96, 128, 256], - 'format' => 'webp', - ], - [ - 'src' => 'pwa/1920x1920.svg', - 'sizes' => [48, 72, 96, 128, 256], - 'format' => 'png', - 'purpose' => 'maskable', - ], - [ - 'src' => 'pwa/1920x1920.svg', - 'sizes' => [0], - ], - ], - 'id' => '/?homescreen=1', - 'launch_handler' => [ - 'client_mode' => ['focus-existing', 'auto'], - ], - 'orientation' => 'portrait-primary', - 'prefer_related_applications' => true, - 'dir' => 'rtl', - 'lang' => 'ar', - 'name' => 'pwa.name', - 'short_name' => 'pwa.short_name', - 'protocol_handlers' => [ - [ - 'protocol' => 'web+jngl', - 'url' => '/lookup?type=%s', ], - [ - 'protocol' => 'web+jnglstore', - 'url' => '/shop?for=%s', - ], - ], - 'related_applications' => [ - [ - 'platform' => 'play', - 'url' => 'https://play.google.com/store/apps/details?id=com.example.app1', - 'id' => 'com.example.app1', - ], - [ - 'platform' => 'itunes', - 'url' => 'https://itunes.apple.com/app/example-app1/id123456789', - ], - [ - 'platform' => 'windows', - 'url' => 'https://apps.microsoft.com/store/detail/example-app1/id123456789', + 'icons' => [ + [ + 'src' => 'pwa/1920x1920.svg', + 'sizes' => [48, 72, 96, 128, 256], + 'format' => 'webp', + ], + [ + 'src' => 'pwa/1920x1920.svg', + 'sizes' => [48, 72, 96, 128, 256], + 'format' => 'png', + 'purpose' => 'maskable', + ], + [ + 'src' => 'pwa/1920x1920.svg', + 'sizes' => [0], + ], ], - ], - 'scope' => '/', - 'start_url' => 'pwa.start_url', - 'theme_color' => 'red', - 'screenshots' => [ - [ - 'src' => 'pwa/screenshots/360x800.svg', - 'label' => 'pwa.screenshots.0', + 'id' => '/?homescreen=1', + 'launch_handler' => [ + 'client_mode' => ['focus-existing', 'auto'], + ], + 'orientation' => 'portrait-primary', + 'prefer_related_applications' => true, + 'dir' => 'rtl', + 'lang' => 'ar', + 'name' => 'pwa.name', + 'short_name' => 'pwa.short_name', + 'protocol_handlers' => [ + [ + 'protocol' => 'web+jngl', + 'url' => '/lookup?type=%s', + ], + [ + 'protocol' => 'web+jnglstore', + 'url' => '/shop?for=%s', + ], ], - ], - 'share_target' => [ - 'action' => [ - 'path' => 'shared_content_receiver', - 'params' => [ - 'param1' => 'value1', - 'param2' => 'value2', + 'related_applications' => [ + [ + 'platform' => 'play', + 'url' => 'https://play.google.com/store/apps/details?id=com.example.app1', + 'id' => 'com.example.app1', + ], + [ + 'platform' => 'itunes', + 'url' => 'https://itunes.apple.com/app/example-app1/id123456789', + ], + [ + 'platform' => 'windows', + 'url' => 'https://apps.microsoft.com/store/detail/example-app1/id123456789', ], ], - 'method' => 'GET', - 'params' => [ - 'title' => 'name', - 'text' => 'description', - 'url' => 'link', + 'scope' => '/', + 'start_url' => 'pwa.start_url', + 'theme_color' => 'red', + 'screenshots' => [ + [ + 'src' => 'pwa/screenshots/360x800.svg', + 'label' => 'pwa.screenshots.0', + ], ], - ], - 'shortcuts' => [ - [ - 'name' => "Today's agenda", - 'url' => [ - 'path' => 'agenda', + 'share_target' => [ + 'action' => [ + 'path' => 'shared_content_receiver', 'params' => [ - 'date' => 'today', + 'param1' => 'value1', + 'param2' => 'value2', ], ], - 'description' => 'List of events planned for today', - ], - [ - 'name' => 'New event', - 'url' => '/create/event', + 'method' => 'GET', + 'params' => [ + 'title' => 'name', + 'text' => 'description', + 'url' => 'link', + ], ], - [ - 'name' => 'New reminder', - 'url' => '/create/reminder', - 'icons' => [ - 'pwa/1920x1920.svg', - [ - 'src' => 'pwa/1920x1920.svg', - 'purpose' => 'maskable', + 'shortcuts' => [ + [ + 'name' => "Today's agenda", + 'url' => [ + 'path' => 'agenda', + 'params' => [ + 'date' => 'today', + ], + ], + 'description' => 'List of events planned for today', + ], + [ + 'name' => 'New event', + 'url' => '/create/event', + ], + [ + 'name' => 'New reminder', + 'url' => '/create/reminder', + 'icons' => [ + 'pwa/1920x1920.svg', + [ + 'src' => 'pwa/1920x1920.svg', + 'purpose' => 'maskable', + ], ], ], ], - ], - 'edge_side_panel' => [ - 'preferred_width' => 480, - ], - 'iarc_rating_id' => '123456', - 'scope_extensions' => [ - [ - 'origin' => '*.foo.com', - ], - [ - 'origin' => 'https://*.bar.com', + 'edge_side_panel' => [ + 'preferred_width' => 480, ], - [ - 'origin' => 'https://*.baz.com', + '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' => 'app_widget_template', - 'data' => 'app_widget_data', - 'type' => 'application/json', - 'screenshots' => [ - [ - 'src' => 'pwa/1920x1920.svg', - 'label' => 'The PWAmp mini-player widget', + 'widgets' => [ + [ + 'name' => 'PWAmp mini player', + 'description' => 'widget to control the PWAmp music player', + 'tag' => 'pwamp', + 'template' => 'pwamp-template', + 'ms_ac_template' => 'app_widget_template', + 'data' => 'app_widget_data', + 'type' => 'application/json', + 'screenshots' => [ + [ + 'src' => 'pwa/1920x1920.svg', + 'label' => 'The PWAmp mini-player widget', + ], ], - ], - 'icons' => [ - [ - 'src' => 'pwa/1920x1920.svg', - 'sizes' => [16, 48], - 'format' => 'webp', + 'icons' => [ + [ + 'src' => 'pwa/1920x1920.svg', + 'sizes' => [16, 48], + 'format' => 'webp', + ], ], + 'auth' => false, + 'update' => 86400, ], - 'auth' => false, - 'update' => 86400, ], + 'handle_links' => 'auto', ], - 'handle_links' => 'auto', 'serviceworker' => [ + 'enabled' => true, 'src' => __DIR__ . '/sw.js', 'scope' => '/', 'use_cache' => true, + 'workbox' => [ + 'warm_cache_urls' => ['privacy_policy', 'terms_of_service'], + 'offline_fallback' => '/offline.html', + ], ], ]); };