From 4fad57a1b3dc5a75cb98d714674d5c549e7704fa Mon Sep 17 00:00:00 2001 From: "Buster \"Silver Eagle\" Neece" Date: Sun, 6 Jun 2021 17:31:14 -0500 Subject: [PATCH] Add service worker for PWA functionality. --- config/routes/public.php | 5 +- src/Assets.php | 47 +++++-- .../AppManifestAction.php | 2 +- .../Frontend/PWA/ServiceWorkerAction.php | 118 ++++++++++++++++++ templates/frontend/public/index.phtml | 13 ++ 5 files changed, 176 insertions(+), 9 deletions(-) rename src/Controller/Frontend/{PublicPages => PWA}/AppManifestAction.php (98%) create mode 100644 src/Controller/Frontend/PWA/ServiceWorkerAction.php diff --git a/config/routes/public.php b/config/routes/public.php index b0aadd2f3..2ecbd1a1f 100644 --- a/config/routes/public.php +++ b/config/routes/public.php @@ -6,13 +6,16 @@ use Slim\App; use Slim\Routing\RouteCollectorProxy; return function (App $app) { + $app->get('/sw.js', Controller\Frontend\PWA\ServiceWorkerAction::class) + ->setName('public:sw'); + $app->group( '/public/{station_id}', function (RouteCollectorProxy $group) { $group->get('[/{embed:embed|social}]', Controller\Frontend\PublicPages\PlayerAction::class) ->setName('public:index'); - $group->get('/app.webmanifest', Controller\Frontend\PublicPages\AppManifestAction::class) + $group->get('/app.webmanifest', Controller\Frontend\PWA\AppManifestAction::class) ->setName('public:manifest'); $group->get('/embed-requests', Controller\Frontend\PublicPages\RequestsAction::class) diff --git a/src/Assets.php b/src/Assets.php index e71489c0d..4f4b156cb 100644 --- a/src/Assets.php +++ b/src/Assets.php @@ -482,6 +482,35 @@ class Assets return $compiled_attributes; } + /** + * @return string[] The paths to all currently loaded files. + */ + public function getLoadedFiles(): array + { + $this->sort(); + + $result = []; + foreach ($this->loaded as $item) { + if (!empty($item['files']['js'])) { + foreach ($item['files']['js'] as $file) { + if (isset($file['src'])) { + $result[] = $this->getUrl($file['src']); + } + } + } + + if (!empty($item['files']['css'])) { + foreach ($item['files']['css'] as $file) { + if (isset($file['href'])) { + $result[] = $this->getUrl($file['href']); + } + } + } + } + + return $result; + } + /** * Resolve the URI of the resource, whether local or remote/CDN-based. * @@ -495,7 +524,7 @@ class Assets $resource_uri = $this->versioned_files[$resource_uri]; } - if (preg_match('/^(https?:)?\/\//', $resource_uri)) { + if (str_starts_with($resource_uri, 'http')) { $this->addDomainToCsp($resource_uri); return $resource_uri; } @@ -528,13 +557,17 @@ class Assets // CSP JavaScript policy // Note: unsafe-eval included for Vue template compiling - $csp_script_src = $this->getCspDomains(); - $csp_script_src[] = "'self'"; - $csp_script_src[] = "'unsafe-eval'"; - $csp_script_src[] = "'nonce-" . $this->getCspNonce() . "'"; + $cspScriptSrc = $this->getCspDomains(); + $cspScriptSrc[] = "'self'"; + $cspScriptSrc[] = "'unsafe-eval'"; + $cspScriptSrc[] = "'nonce-" . $this->getCspNonce() . "'"; + $csp[] = 'script-src ' . implode(' ', $cspScriptSrc); - $csp[] = 'script-src ' . implode(' ', $csp_script_src); - $csp[] = 'worker-src blob:'; + $cspWorkerSrc = []; + $cspWorkerSrc[] = "blob:"; + $cspWorkerSrc[] = "'self'"; + + $csp[] = 'worker-src ' . implode(' ', $cspWorkerSrc); return $response->withHeader('Content-Security-Policy', implode('; ', $csp)); } diff --git a/src/Controller/Frontend/PublicPages/AppManifestAction.php b/src/Controller/Frontend/PWA/AppManifestAction.php similarity index 98% rename from src/Controller/Frontend/PublicPages/AppManifestAction.php rename to src/Controller/Frontend/PWA/AppManifestAction.php index 69c626b08..38132c65c 100644 --- a/src/Controller/Frontend/PublicPages/AppManifestAction.php +++ b/src/Controller/Frontend/PWA/AppManifestAction.php @@ -1,6 +1,6 @@ { + // Kick out the old service worker + self.skipWaiting(); + + event.waitUntil( + caches.open(cacheName).then(cache => { + return cache.addAll(cacheFiles); + }) + ); + }); + + self.addEventListener('activate', event => { + // Delete any non-current cache + event.waitUntil( + caches.keys().then(keys => { + Promise.all( + keys.map(key => { + if (![cacheName].includes(key)) { + return caches.delete(key); + } + }) + ); + }) + ); + }); + + // Offline-first, cache-first strategy + // Kick off two asynchronous requests, one to the cache and one to the network + // If there's a cached version available, use it, but fetch an update for next time. + // Gets data on screen as quickly as possible, then updates once the network has returned the latest data. + self.addEventListener('fetch', event => { + event.respondWith( + caches.open(cacheName).then(cache => { + return cache.match(event.request).then(response => { + return response || fetch(event.request).then(networkResponse => { + cache.put(event.request, networkResponse.clone()); + return networkResponse; + }); + }); + }) + ); + }); + JS; + + public function __invoke( + ServerRequest $request, + Response $response, + Environment $environment, + FactoryInterface $factory + ): ResponseInterface { + $assets = $factory->make( + Assets::class, + [ + 'request' => $request, + ] + ); + + $swContents = self::SW_BASE; + + $findString = 'const cacheFiles = [];'; + if (!str_contains($swContents, $findString)) { + throw new \RuntimeException('Service worker template does not contain proper placeholder.'); + } + + $cacheFiles = []; + + // Cache the compiled assets that would be used on public players. + $assets->load('minimal') + ->load('Vue_PublicFullPlayer'); + + $loadedFiles = $assets->getLoadedFiles(); + + foreach ($loadedFiles as $file) { + if (!str_starts_with($file, 'http')) { + $cacheFiles[] = $file; + } + } + + // Cache images and icons + $staticBase = $environment->getBaseDirectory() . '/web' . $environment->getAssetUrl(); + + $otherStaticFiles = Finder::create() + ->files() + ->in($staticBase) + ->depth('>=1') + ->exclude(['dist', 'api']); + + foreach($otherStaticFiles as $file) { + $cacheFiles[] = $environment->getAssetUrl().'/'.$file->getRelativePathname(); + } + + $replaceString = 'const cacheFiles = ' . json_encode($cacheFiles) . ';'; + + $swContents = str_replace($findString, $replaceString, $swContents); + + return $response + ->withHeader('Content-Type', 'text/javascript') + ->write($swContents); + } +} diff --git a/templates/frontend/public/index.phtml b/templates/frontend/public/index.phtml index c76506188..c9a5bb2cd 100644 --- a/templates/frontend/public/index.phtml +++ b/templates/frontend/public/index.phtml @@ -38,6 +38,19 @@ if ($customization->useWebSocketsForNowPlaying()) { $assets ->addVueRender('Vue_PublicFullPlayer', '#public-radio-player', $props); +// Register PWA service worker +$swJsRoute = (string)$router->named('public:sw'); + +$assets->addInlineJs( + <<push('head'); ?>