diff --git a/plugins/pwa/assets/wifi.svg b/plugins/pwa/assets/wifi.svg new file mode 100644 index 00000000..1eb43503 --- /dev/null +++ b/plugins/pwa/assets/wifi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/pwa/js/pwa.js b/plugins/pwa/js/pwa.js index 1dff6705..9f8bf733 100644 --- a/plugins/pwa/js/pwa.js +++ b/plugins/pwa/js/pwa.js @@ -1,106 +1,78 @@ /** * PWA plugin engine * * @author Christian Mollekopf * * @licstart The following is the entire license notice for the * JavaScript code in this file. * * Copyright (C) 2019, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * @licend The above is the entire license notice * for the JavaScript code in this file. */ // Service worker (required by Android/Chrome but not iOS/Safari) if ('serviceWorker' in navigator) { navigator.serviceWorker.register('?PWA=sw.js') .then(function(reg) { console.log("ServiceWorker registered", reg); }) .catch(function(error) { console.log("Failed to register ServiceWorker", error); }); } -/* -function registerOneTimeSync() { - if (navigator.serviceWorker.controller) { - navigator.serviceWorker.ready.then(function(reg) { - if (reg.sync) { - reg.sync.register({ - tag: 'oneTimeSync' - }) - .then(function(event) { - console.log('Sync registration successful', event); - }) - .catch(function(error) { - console.log('Sync registration failed', error); - }); - } else { - console.log("One time Sync not supported"); - } - }); - } else { - console.log("No active ServiceWorker"); - } -} -*/ - -// Offline banner +// Offline overlay +var pwa_online = true; function updateOnlineStatus() { - // FIXME fill in something that makes sense in roundcube - // var d = document.body; - // d.className = d.className.replace(/\ offline\b/,''); - // if (!navigator.onLine) { - // d.className += " offline"; - // } -} -updateOnlineStatus(); + if (!navigator.onLine) { + var overlay = document.createElement('div'), + img_src = rcmail.assets_path('plugins/pwa/assets/wifi.svg'); -window.addEventListener('load', function() { - window.addEventListener('online', updateOnlineStatus); - window.addEventListener('offline', updateOnlineStatus); -}); + overlay.id = 'pwa-offline-overlay'; + overlay.style.cssText = 'position: absolute; top: 0; bottom: 0; width: 100%; z-index: 20000;' + + ' display: flex; flex-direction: column; align-items: center; justify-content: center;' + + ' opacity: .85; background-color: #000; color: #fff;'; + overlay.innerHTML = '' + + '
No internet connection
'; + overlay.onclick = function(e) { e.stopPropagation(); }; -/* TODO: Do we need this to anything useful? -// Change page title depending on document visibility -function handleVisibilityChange() { - if (document.visibilityState == "hidden") { - document.title = "Hey! Come back!"; - } else { - document.title = original_title; + document.body.appendChild(overlay); + + pwa_online = false; } -} + else { + var overlay = document.getElementById('pwa-offline-overlay'); + if (overlay) { + document.body.removeChild(overlay); + } -var original_title = document.title; -document.addEventListener('visibilitychange', handleVisibilityChange, false); -*/ + // When becoming online again, send keep-alive request to check if the session + // is still valid, if not user will be redirected to the logon screen + if (!pwa_online && rcmail.task != 'login') { + rcmail.keep_alive(); + } -/* TODO: Do we need this to anything useful? -// Notifications -window.addEventListener('load', function () { - // At first, let's check if we have permission for notification - // If not, let's ask for it - if (window.Notification && Notification.permission !== "granted") { - Notification.requestPermission(function (status) { - if (Notification.permission !== status) { - Notification.permission = status; - } - }); + pwa_online = true; } +} + +window.addEventListener('load', function() { + updateOnlineStatus(); + window.addEventListener('online', updateOnlineStatus); + window.addEventListener('offline', updateOnlineStatus); }); -*/ diff --git a/plugins/pwa/js/sw.js b/plugins/pwa/js/sw.js index c0468c42..d5927560 100644 --- a/plugins/pwa/js/sw.js +++ b/plugins/pwa/js/sw.js @@ -1,102 +1,88 @@ /** * PWA service worker * * @author Christian Mollekopf * * @licstart The following is the entire license notice for the * JavaScript code in this file. * * Copyright (C) 2019, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * @licend The above is the entire license notice * for the JavaScript code in this file. */ // Warning: cacheName and assetsToCache vars are set by the PWA plugin // when sending this file content to the browser -self.addEventListener('sync', function(event) { - if (event.registration.tag == "oneTimeSync") { - console.dir(self.registration); - console.log("One Time Sync Fired"); - } -}); - self.addEventListener('install', function(event) { // waitUntil() ensures that the Service Worker will not // install until the code inside has successfully occurred event.waitUntil( // Create cache with the name supplied above and // return a promise for it caches.open(cacheName).then(function(cache) { // Important to `return` the promise here to have `skipWaiting()` // fire after the cache has been updated. return cache.addAll(assetsToCache); }).then(function() { // `skipWaiting()` forces the waiting ServiceWorker to become the // active ServiceWorker, triggering the `onactivate` event. // Together with `Clients.claim()` this allows a worker to take effect // immediately in the client(s). return self.skipWaiting(); }) ); }); // Activate event // Be sure to call self.clients.claim() self.addEventListener('activate', function(event) { // `claim()` sets this worker as the active worker for all clients that // match the workers scope and triggers an `oncontrollerchange` event for // the clients. return self.clients.claim(); }); self.addEventListener('fetch', function(event) { - // Ignore non-get request like when accessing the admin panel - // if (event.request.method !== 'GET') { return; } - // Don't try to handle non-secure assets because fetch will fail - if (/http:/.test(event.request.url)) { - return; - } - // Here's where we cache all the things! event.respondWith( // Open the cache created when install caches.open(cacheName).then(function(cache) { // Go to the network to ask for that resource return fetch(event.request).then(function(networkResponse) { // Add a copy of the response to the cache (updating the old version) cache.put(event.request, networkResponse.clone()); // Respond with it return networkResponse; }).catch(function() { // If there is no internet connection, try to match the request // to some of our cached resources return cache.match(event.request); }) }) ); }); self.addEventListener('beforeinstallprompt', function(e) { e.userChoice.then(function(choiceResult) { if (choiceResult.outcome == 'dismissed') { alert('User cancelled home screen install'); } else { alert('User added to home screen'); } }); }); diff --git a/plugins/pwa/pwa.php b/plugins/pwa/pwa.php index 45046403..c07fb251 100644 --- a/plugins/pwa/pwa.php +++ b/plugins/pwa/pwa.php @@ -1,339 +1,339 @@ * @author Christian Mollekopf * * Copyright (C) 2019, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class pwa extends rcube_plugin { /** @var string $version Plugin version */ - public static $version = '0.1'; + public static $version = '0.2'; /** @var array $config Plugin config */ private static $config; /** * Plugin initialization */ function init() { $this->add_hook('template_object_links', array($this, 'template_object_links')); $this->add_hook('template_object_meta', array($this, 'template_object_meta')); $this->include_script('js/pwa.js'); // Set the skin for PWA mode if (!empty($_GET['PWAMODE']) || !empty($_SESSION['PWAMODE'])) { $rcube = rcube::get_instance(); $skin = $_SESSION['PWAMODE']; if (!$skin) { $config = self::get_config(); $skin = $config['skin']; } // Reset the skin to the responsive one if ($rcube->config->get('skin') != $skin) { if ($rcube->output->type == 'html') { $rcube->output->set_skin($skin); } $rcube->config->set('skin', $skin, true); // Reset default skin, otherwise it will be reset to default in rcmail::kill_session() // TODO: This could be done better $rcube->default_skin = $skin; } // Disable skin switch as this wouldn't have any effect // It also makes sure that user skin is not applied // TODO: Allow skin selection if there's more than one responsive skin available $dont_override = (array) $rcube->config->get('dont_override'); if (!in_array('skin', $dont_override)) { $dont_override[] = 'skin'; $rcube->config->set('dont_override', $dont_override, true); } // Set the mode for the client environment $rcube->output->set_env('PWAMODE', true); // Remember the mode in session $rcube->add_shutdown_function(function() use ($skin) { $_SESSION['PWAMODE'] = $skin; }); } } /** * Adds elements to the HTML output (handler for 'template_object_links' hook) */ public function template_object_links($args) { $rcube = rcube::get_instance(); $config = $this->get_config(); $content = ''; $links = array( array( 'rel' => 'manifest', 'href' => '?PWA=manifest.json', ), array( 'rel' => 'apple-touch-icon', 'sizes' => '180x180', 'href' => 'apple-touch-icon.png' ), array( 'rel' => 'icon', 'type' => 'image/png', 'sizes' => '32x32', 'href' => 'favicon-32x32.png' ), array( 'rel' => 'icon', 'type' => 'image/png', 'sizes' => '16x16', 'href' => 'favicon-16x16.png' ), array( 'rel' => 'mask-icon', 'href' => 'safari-pinned-tab.svg', 'color' => $config['pinned_tab_color'] ?: $config['theme_color'], ), ); // Check if the skin contains /pwa directory $root_url = $rcube->find_asset('skins/' . $config['skin'] . '/pwa') ?: ($this->urlbase . 'assets'); foreach ($links as $link) { if ($link['href'][0] != '?') { $link['href'] = $root_url . '/' . $link['href']; } $content .= html::tag('link', $link) . "\n"; } // replace favicon.ico $icon = $root_url . '/favicon.ico'; $args['content'] = preg_replace('/( elements to the HTML output (handler for 'template_object_meta' hook) */ public function template_object_meta($args) { $config = $this->get_config(); $meta_content = ''; $meta_list = array( 'apple-mobile-web-app-title' => 'name', 'application-name' => 'name', 'msapplication-TileColor' => 'tile_color', // todo: theme-color meta is already added by the skin, overwrite? 'theme-color' => 'theme_color', ); foreach ($meta_list as $name => $opt_name) { if ($content = $config[$opt_name]) { $meta_content .= html::tag('meta', array('name' => $name, 'content' => $content)) . "\n"; } } $args['content'] .= $meta_content; return $args; } /** * Hijack HTTP requests to plugin assets e.g. service worker */ public static function http_request() { // We register service worker file from location specified // as ?PWA=sw.js. This way we don't need to put it in Roundcube root // and we can set some javascript variables like cache version, etc. if ($_GET['PWA'] === 'sw.js') { $rcube = rcube::get_instance(); $rcube->task = 'pwa'; $rcube->action = 'sw.js'; if ($file = $rcube->find_asset('plugins/pwa/js/sw.js')) { // TODO: use caching headers? header('Content-Type: application/javascript'); // TODO: What assets do we want to cache? // TODO: assets_dir support $assets = array( -// 'plugins/pwa/assets/manifest.json', + $rcube->find_asset('plugins/pwa/assets/wifi.svg'), ); echo "var cacheName = 'v" . self::$version . "';\n"; echo "var assetsToCache = " . json_encode($assets) . "\n"; readfile($file); exit; } header('HTTP/1.0 404 PWA plugin error'); exit; } // We genarate manifest.json file from skin/plugin config if ($_GET['PWA'] === 'manifest.json') { $rcube = rcube::get_instance(); $rcube->task = 'pwa'; $rcube->action = 'manifest.json'; // Read skin/plugin config $config = self::get_config(); // HTTP scope $scope = preg_replace('|/*\?.*$|', '', $_SERVER['REQUEST_URI']); $scope = strlen($scope) ? $scope : ''; // Check if the skin contains /pwa directory $root_url = $rcube->find_asset('skins/' . $config['skin'] . '/pwa') ?: ('plugins/pwa/assets'); // Manifest defaults $defaults = array( 'name' => null, 'short_name' => $config['name'], 'description' => 'Free and Open Source Webmail', 'lang' => 'en-US', 'theme_color' => null, 'background_color' => null, 'pinned_tab_color' => null, 'icons' => array( array( 'src' => $root_url . '/android-chrome-192x192.png', 'sizes' => '192x192', 'type' => 'image/png', ), array( 'src' => $root_url . '/android-chrome-512x512.png', 'sizes' => '512x512', 'type' => 'image/png', ), ), ); $manifest = array( 'scope' => $scope, 'start_url' => '?PWAMODE=1', 'display' => 'standalone', /* 'permissions' => array( 'desktop-notification' => array( 'description' => "Needed for notifying you of any changes to your account." ), ), */ ); // Build manifest data from config and defaults foreach ($defaults as $name => $value) { if (isset($config[$name])) { $value = $config[$name]; } $manifest[$name] = $value; } // Send manifest.json to the browser // TODO: use caching headers? header('Content-Type: application/json'); echo rcube_output::json_serialize($manifest, (bool) $rcube->config->get('devel_mode')); exit; } } /** * Load plugin and skin configuration * * @return array Key-value configuration */ private static function get_config() { if (is_array(self::$config)) { return self::$config; } $rcube = rcube::get_instance(); $config = array(); self::$config = array(); $defaults = array( 'tile_color' => '#2d89ef', 'theme_color' => '#f4f4f4', 'pinned_tab_color' => '#37beff', 'background_color' => '#ffffff', 'skin' => $rcube->config->get('skin') ?: 'elastic', 'name' => $rcube->config->get('product_name') ?: 'Roundcube', ); // Load plugin config into $config var $fpath = __DIR__ . '/config.inc.php'; if (is_file($fpath) && is_readable($fpath)) { ob_start(); include($fpath); ob_end_clean(); } // Load skin config $meta = @file_get_contents(RCUBE_INSTALL_PATH . '/skins/' . $defaults['skin'] . '/meta.json'); $meta = @json_decode($meta, true); if ($meta && $meta['extends']) { // Merge with parent skin config $root_meta = @file_get_contents(RCUBE_INSTALL_PATH . '/skins/' . $meta['extends'] . '/meta.json'); $root_meta = @json_decode($meta, true); if ($root_meta && !empty($root_meta['config'])) { $meta['config'] = array_merge((array) $root_meta['config'], (array) $meta['config']); } } foreach ((array) $meta['config'] as $name => $value) { if (strpos($name, 'pwa_') === 0 && !isset($config[$name])) { $config[$name] = $value; } } foreach ($config as $name => $value) { $name = preg_replace('/^pwa_/', '', $name); if ($value !== null) { self::$config[$name] = $value; } } foreach ($defaults as $name => $value) { if (!array_key_exists($name, self::$config)) { self::$config[$name] = $value; } } return self::$config; } } // Hijack HTTP requests to special plugin assets e.g. sw.js, manifest.json pwa::http_request();