diff --git a/.woodpecker/code-style.yml b/.woodpecker/code-style.yml new file mode 100644 index 0000000..8fb9aa6 --- /dev/null +++ b/.woodpecker/code-style.yml @@ -0,0 +1,7 @@ +steps: + code-style: + image: git.digital-competence.de/woodpecker-ci/plugin-phpcs + settings: + args: Classes/ + when: + - event: [push, pull_request, manual] diff --git a/Classes/Http/WebManifestMiddleware.php b/Classes/Http/WebManifestMiddleware.php new file mode 100644 index 0000000..e7a4c0e --- /dev/null +++ b/Classes/Http/WebManifestMiddleware.php @@ -0,0 +1,151 @@ +browserCacheMaxAge = $browserCacheMaxAge; + $this->defaultName = $defaultName; + $this->i18nService = $i18nService; + $this->manifestData = $manifestData; + $this->reactOnPath = $reactOnPath; + $this->resourceManager = $resourceManager; + $this->responseCache = $responseCache; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($request->getUri()->getPath() === $this->reactOnPath) { + return $this->createResponse($request); + } + return $handler->handle($request); + } + + /** + * @param ServerRequestInterface $request + * @return ResponseInterface + * @throws CacheException + * @throws InvalidDataException + * @throws \JsonException + * @throws Exception + * @throws MissingActionNameException + */ + protected function createResponse(ServerRequestInterface $request): ResponseInterface + { + $cacheId = (string)$this->i18nService->getConfiguration()->getCurrentLocale(); + if ($this->responseCache->has($cacheId)) { + $response = $this->responseCache->get($cacheId); + } else { + $result = $this->manifestData; + if ($result['name'] === null) { + $result['name'] = $this->defaultName; + } + if ($result['short_name'] === null) { + $result['short_name'] = $this->defaultName; + } + if ($result['lang'] === null) { + $result['lang'] = \str_replace( + '_', + '-', + (string)$this->i18nService->getConfiguration()->getCurrentLocale() + ); + } + foreach ($this->uriProperties as $uriProperty) { + if (\is_array($result[$uriProperty])) { + $uriBuilder = new UriBuilder(); + $uriBuilder->setRequest(ActionRequest::fromHttpRequest($request)); + if (isset($result[$uriProperty]['format']) && \is_string($result[$uriProperty]['format'])) { + $uriBuilder->setFormat($result[$uriProperty]['format']); + } + if (!isset($result[$uriProperty]['action'])) { + throw new \InvalidArgumentException( + 'if ' . $uriProperty . ' is in array form, action is required', + 1718957544 + ); + } + $result[$uriProperty] = $uriBuilder->uriFor( + $result[$uriProperty]['action'], + $result[$uriProperty]['arguments'] ?? [], + $result[$uriProperty]['controller'] ?? null, + $result[$uriProperty]['package'] ?? null, + $result[$uriProperty]['subPackage'] ?? null, + ); + } + } + foreach ($result['icons'] as &$iconData) { + if (!isset($iconData['src']) || !isset($iconData['sizes']) || !isset($iconData['type'])) { + throw new \InvalidArgumentException( + 'Icons in manifests require src, sizes and type', + 1718961281 + ); + } + if (\str_starts_with($iconData['src'], 'resource://')) { + $iconData['src'] = $this->resourceManager->getPublicPackageResourceUriByPath($iconData['src']); + } + } + unset($iconData); + + if ($result['id'] === null) { + $result['id'] = $result['start_url']; + } + + $response = \json_encode($result, \JSON_THROW_ON_ERROR); + $this->responseCache->set($cacheId, $response); + } + return new Response( + 200, + [ + 'Content-Type' => 'application/manifest+json', + 'Cache-Control' => 'max-age=' . $this->browserCacheMaxAge . ', must-revalidate', + 'Last-Modified' => $this->getCacheDate(), + 'Vary' => 'Accept, Accept-Language' + ], + $response + ); + } + + protected function getCacheDate(): string + { + if (false === $lastModified = $this->responseCache->get('lastModified')) { + $lastModified = (new \DateTimeImmutable())->format(\DATE_RFC7231); + $this->responseCache->set('lastModified', $lastModified); + } + return $lastModified; + } +} diff --git a/Classes/ViewHelpers/WebManifestViewHelper.php b/Classes/ViewHelpers/WebManifestViewHelper.php new file mode 100644 index 0000000..52ed4b3 --- /dev/null +++ b/Classes/ViewHelpers/WebManifestViewHelper.php @@ -0,0 +1,35 @@ +registerArgument('useCredentials', 'bool', 'does the manifest requires authentication'); + } + + public function render(): string + { + if ($this->arguments['useCredentials'] === true) { + $this->tag->addAttribute('crossorigin', 'use-credentials'); + } + $this->tag->addAttribute('rel', 'manifest'); + $this->tag->addAttribute('href', $this->reactOnPath); + return $this->tag->render(); + } +} diff --git a/Configuration/Caches.yaml b/Configuration/Caches.yaml new file mode 100644 index 0000000..1613278 --- /dev/null +++ b/Configuration/Caches.yaml @@ -0,0 +1,4 @@ +DigiComp_FlowWebManifestUtils_Responses: + frontend: Neos\Cache\Frontend\StringFrontend + #backend: Neos\Cache\Backend\SimpleFileBackend + backend: Neos\Cache\Backend\TransientMemoryBackend diff --git a/Configuration/Objects.yaml b/Configuration/Objects.yaml new file mode 100644 index 0000000..5eacc1b --- /dev/null +++ b/Configuration/Objects.yaml @@ -0,0 +1,26 @@ +DigiComp.FlowWebManifestUtils:ManifestResponseCache: + className: "Neos\\Cache\\Frontend\\StringFrontend" + factoryObjectName: "Neos\\Flow\\Cache\\CacheManager" + factoryMethodName: "getCache" + arguments: + 1: + value: "DigiComp_FlowWebManifestUtils_Responses" + +DigiComp.FlowWebManifestUtils:WebManifestMiddleware: + className: "DigiComp\\FlowWebManifestUtils\\Http\\WebManifestMiddleware" + autowiring: true + arguments: + 1: + setting: "DigiComp.FlowWebManifestUtils.browserCacheMaxAge" + 2: + setting: "Neos.Flow.core.applicationName" + 3: + object: "Neos\\Flow\\I18n\\Service" + 4: + setting: "DigiComp.FlowWebManifestUtils.manifest" + 5: + setting: "DigiComp.FlowWebManifestUtils.reactOnPath" + 6: + object: "Neos\\Flow\\ResourceManagement\\ResourceManager" + 7: + object: "DigiComp.FlowWebManifestUtils:ManifestResponseCache" diff --git a/Configuration/Settings.Manifest.yaml b/Configuration/Settings.Manifest.yaml new file mode 100644 index 0000000..762cea4 --- /dev/null +++ b/Configuration/Settings.Manifest.yaml @@ -0,0 +1,39 @@ +DigiComp: + FlowWebManifestUtils: + manifest: + # Splash screen background color (hex value) + background_color: '' + + description: '' + # valid values: 'ltr', 'rtl', 'auto' + dir: 'auto' + # Valid values: 'browser', 'standalone', 'minimal-ui', 'fullscreen' + display: 'standalone' + # Array/List of icons with relative rendered path + # example: + # { + # "src": "resource://Vendor.Site/Public/Icons/icon-72x72.png", + # "sizes": "72x72", + # "type": "image/png" + # } + icons: [ ] + # if null, the start_url will forge its identity + id: ~ + # Language of your page, e.g. 'en-EN' or 'de-DE', null for auto inferring from current locale + lang: ~ + # App name, null for reuse of Neos.Flow.core.applicationName + name: ~ + # Valid values: 'landscape', 'portrait' + orientation: 'any' + # See https://w3c.github.io/manifest/#related_applications-member, currently out of scope + #prefer_related_applications: [] + #related_applications: [] + # Application scope + scope: '/' + # Name below your app icon, null for reuse of Neos.Flow.core.applicationName + short_name: ~ + shortcuts: [] + # Uri or array in the shape: {action: string, arguments: string, controller: string, packageKey: string, format: string} + start_url: '/' + # Hex value + theme_color: '#fff' diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml new file mode 100644 index 0000000..dde6403 --- /dev/null +++ b/Configuration/Settings.yaml @@ -0,0 +1,12 @@ +DigiComp: + FlowWebManifestUtils: + reactOnPath: '/app.webmanifest' + browserCacheMaxAge: 3600 + +Neos: + Flow: + http: + middlewares: + webManifest: + position: "start 100" + middleware: "DigiComp.FlowWebManifestUtils:WebManifestMiddleware" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7c439ae --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024 Ferdinand Kuhl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c1b3d3 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# DigiComp.FlowWebManifestUtils + +This package provides small helpers to provide a PWA webmanifest. + +It consists of two parts: a middleware, which will return the manifest to the browser and a view helper, which makes +it easy to integrate your manifest to your (fluid template). + +All you need to do is to integrate the Manifest-VieHelper to the `head` section of your template and adjust the +`DigiComp.FlowWebmanifestUtils.manifest` values in Settings.yaml to match your needs. + +Let me provide you an example: +```html + + + + + +``` + +That is the minimum you need to do, to have working manifest for your application. + +The start_url - Parameter in the diff --git a/Resources/Private/Schema/Settings/DigiComp.FlowWebManifestUtils.manifest.schema.yaml b/Resources/Private/Schema/Settings/DigiComp.FlowWebManifestUtils.manifest.schema.yaml new file mode 100644 index 0000000..b60b954 --- /dev/null +++ b/Resources/Private/Schema/Settings/DigiComp.FlowWebManifestUtils.manifest.schema.yaml @@ -0,0 +1,49 @@ +type: dictionary +additionalProperties: false +properties: + name: + type: ["string", "null"] + short_name: + type: ["string", "null"] + lang: + type: ["string", "null"] + theme_color: + type: ["string", "null"] + background_color: + type: ["string", "null"] + display: + enum: ["browser", "standalone", "minimal-ui", "fullscreen"] + orientation: + enum: ["landscape", "portrait", "any"] + scope: + type: ["string"] + start_url: + type: ["string", "dictionary"] + properties: + action: + type: ["string"] + controller: + type: ["string"] + package: + type: ["string"] + subPackage: + type: ["string"] + arguments: + type: ["string"] + format: + type: ["string"] + icons: + type: ["array"] + required: true + items: + type: "dictionary" + additionalProperties: false + required: true + minItems: 1 + properties: + src: + type: ["string"] + sizes: + type: ["string"] + type: + type: ["string"] diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..82ee01a --- /dev/null +++ b/composer.json @@ -0,0 +1,23 @@ +{ + "name": "digicomp/flow-webmanifest-utils", + "type": "neos-package", + "description": "", + "require": { + "ext-json": "*", + "neos/flow": "^6.3 | ^7.3 | ^8.3", + "php": ">= 7.4", + "symfony/polyfill-php80": "^1.16" + }, + "autoload": { + "psr-4": { + "DigiComp\\FlowWebManifestUtils\\": "Classes" + } + }, + "extra": { + "branch-alias": { + "dev-develop": "1.0.x-dev" + }, + "applied-flow-migrations": [ + ] + } +}