Add service worker for PWA functionality.

This commit is contained in:
Buster "Silver Eagle" Neece 2021-06-06 17:31:14 -05:00
parent d0eaabf8e1
commit 4fad57a1b3
No known key found for this signature in database
GPG Key ID: 6D9E12FF03411F4E
5 changed files with 176 additions and 9 deletions

View File

@ -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)

View File

@ -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));
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Controller\Frontend\PublicPages;
namespace App\Controller\Frontend\PWA;
use App\Environment;
use App\Exception\StationNotFoundException;

View File

@ -0,0 +1,118 @@
<?php
namespace App\Controller\Frontend\PWA;
use App\Assets;
use App\Environment;
use App\Http\Response;
use App\Http\ServerRequest;
use DI\FactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Finder\Finder;
class ServiceWorkerAction
{
public const SW_BASE = <<<'JS'
const cacheName = 'assets'; // Change value to force update
const cacheFiles = [];
self.addEventListener('install', event => {
// 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);
}
}

View File

@ -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(
<<<JS
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('${swJsRoute}');
});
}
JS
);
$this->push('head');
?>
<link rel="manifest" href="<?=(string)$router->fromHere('public:manifest')?>">