diff --git a/plugins/pwa/README.md b/plugins/pwa/README.md new file mode 100644 index 00000000..9fb904ba --- /dev/null +++ b/plugins/pwa/README.md @@ -0,0 +1,19 @@ +The plugin "converts" Roundcube into a Progressive Web Application +which can be "installed" into user device's operating system. + +This is proof-of-concept. + + +CONFIGURATION +------------- + +1. Replace images in `/assets` directory to your Company logo. Note, that some +images contain background and some are transpartent. This is to support various +operating systems and the way they use PWAs. + +https://realfavicongenerator.net/ will help you with creating images automatically +and choosing some colors. + +2. You can also modify `/assets/manifest.json` to your liking. + +3. Enable the plugin in Roundcube configuration file. diff --git a/plugins/pwa/assets/android-chrome-192x192.png b/plugins/pwa/assets/android-chrome-192x192.png new file mode 100644 index 00000000..3fb75360 Binary files /dev/null and b/plugins/pwa/assets/android-chrome-192x192.png differ diff --git a/plugins/pwa/assets/android-chrome-512x512.png b/plugins/pwa/assets/android-chrome-512x512.png new file mode 100644 index 00000000..c2cec246 Binary files /dev/null and b/plugins/pwa/assets/android-chrome-512x512.png differ diff --git a/plugins/pwa/assets/apple-touch-icon.png b/plugins/pwa/assets/apple-touch-icon.png new file mode 100644 index 00000000..bbe55ae7 Binary files /dev/null and b/plugins/pwa/assets/apple-touch-icon.png differ diff --git a/plugins/pwa/assets/favicon-16x16.png b/plugins/pwa/assets/favicon-16x16.png new file mode 100644 index 00000000..0c840a2f Binary files /dev/null and b/plugins/pwa/assets/favicon-16x16.png differ diff --git a/plugins/pwa/assets/favicon-32x32.png b/plugins/pwa/assets/favicon-32x32.png new file mode 100644 index 00000000..86916d16 Binary files /dev/null and b/plugins/pwa/assets/favicon-32x32.png differ diff --git a/plugins/pwa/assets/favicon.ico b/plugins/pwa/assets/favicon.ico new file mode 100644 index 00000000..5cb472a2 Binary files /dev/null and b/plugins/pwa/assets/favicon.ico differ diff --git a/plugins/pwa/assets/manifest.json b/plugins/pwa/assets/manifest.json new file mode 100644 index 00000000..1378be8a --- /dev/null +++ b/plugins/pwa/assets/manifest.json @@ -0,0 +1,28 @@ +{ + "name": "Roundcube", + "short_name": "Roundcube", + "description": "Free and Open Source Webmail.", + "lang": "en-US", + "start_url": ".", + "display": "standalone", + "theme_color": "#2e3135", + "background_color": "#2e3135", + "scope": "/", + "icons": [ + { + "src": "android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "permissions": { + "desktop-notification": { + "description": "Needed for notifying you of any changes to your account." + } + } +} diff --git a/plugins/pwa/assets/safari-pinned-tab.svg b/plugins/pwa/assets/safari-pinned-tab.svg new file mode 100644 index 00000000..00455747 --- /dev/null +++ b/plugins/pwa/assets/safari-pinned-tab.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/pwa/composer.json b/plugins/pwa/composer.json new file mode 100644 index 00000000..9087c3e6 --- /dev/null +++ b/plugins/pwa/composer.json @@ -0,0 +1,29 @@ +{ + "name": "kolab/pwa", + "type": "roundcube-plugin", + "description": "COnverts Roundcube into a so-called Progressive Web App for mobile", + "license": "GPLv3+", + "version": "0.1", + "authors": [ + { + "name": "Aleksander Machniak", + "email": "machniak@kolabsys.com", + "role": "Lead" + }, + { + "name": "Christian Mollekopf", + "email": "mollekopf@kolabsys.com", + "role": "Lead" + } + ], + "repositories": [ + { + "type": "composer", + "url": "https://plugins.roundcube.net" + } + ], + "require": { + "php": ">=5.3.0", + "roundcube/plugin-installer": ">=0.1.3" + } +} diff --git a/plugins/pwa/js/pwa.js b/plugins/pwa/js/pwa.js new file mode 100644 index 00000000..21b858be --- /dev/null +++ b/plugins/pwa/js/pwa.js @@ -0,0 +1,101 @@ +/** + * 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 */ +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 */ +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(); + +window.addEventListener('load', function() { + window.addEventListener('online', updateOnlineStatus); + window.addEventListener('offline', updateOnlineStatus); +}); + +/* CHANGE PAGE TITLE BASED ON PAGE VISIBILITY */ +function handleVisibilityChange() { + if (document.visibilityState == "hidden") { + document.title = "Hey! Come back!"; + } else { + document.title = original_title; + } +} + +var original_title = document.title; +document.addEventListener('visibilitychange', handleVisibilityChange, false); + +/* 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; + } + }); + } +}); diff --git a/plugins/pwa/js/sw.js b/plugins/pwa/js/sw.js new file mode 100644 index 00000000..add433a8 --- /dev/null +++ b/plugins/pwa/js/sw.js @@ -0,0 +1,100 @@ +/** + * 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 new file mode 100644 index 00000000..eeb8320d --- /dev/null +++ b/plugins/pwa/pwa.php @@ -0,0 +1,184 @@ + + * @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 +{ + public $noajax = true; + public $noframe = true; + + /** @var string $version Plugin version */ + public static $version = '0.1'; + + /** @var array|null $config Plugin config (from manifest.json) */ + private $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'); + } + + /** + * Adds elements to the HTML output + */ + public function template_object_links($args) + { + $config = $this->get_config(); + $content = ''; + $links = array( + array( + 'rel' => 'manifest', + 'href' => $this->urlbase . 'assets/manifest.json', + ), + array( + 'rel' => 'apple-touch-icon', + 'sizes' => '180x180', + 'href' => $this->urlbase . 'assets/apple-touch-icon.png' + ), + array( + 'rel' => 'icon', + 'type' => 'image/png', + 'sizes' => '32x32', + 'href' => $this->urlbase . 'assets/favicon-32x32.png' + ), + array( + 'rel' => 'icon', + 'type' => 'image/png', + 'sizes' => '16x16', + 'href' => $this->urlbase . 'assets/favicon-16x16.png' + ), + array( + 'rel' => 'mask-icon', + 'href' => $this->urlbase . 'assets/safari-pinned-tab.svg', + 'color' => $config['theme_color'] ?: '#5bbad5', + ), + ); + + foreach ($links as $link) { + $content .= html::tag('link', $link) . "\n"; + } + + $args['content'] .= $content; + + // replace favicon.ico + $args['content'] = preg_replace( + '/(urlbase . 'assets/favicon.ico', + $args['content'] + ); + + return $args; + } + + /** + * Adds elements to the HTML output + */ + 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'; + + if ($file = $rcube->find_asset('plugins/pwa/js/sw.js')) { + header('Content-Type: application/javascript'); + + // TODO: What assets do we want to cache? + // TODO: assets_dir support + $assets = array( +// 'plugins/pwa/assets/manifest.json', + ); + + 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; + } + } + + /** + * Read configuration from manifest.json + * + * @return array Key-value configuration + */ + private function get_config() + { + if (is_array($this->config)) { + return $this->config; + } + + $config = array(); + $defaults = array( + 'tile_color' => '#2d89ef', + 'theme_color' => '#2e3135', + ); + + if ($file = rcube::get_instance()->find_asset('plugins/pwa/assets/manifest.json')) { + $config = json_decode(file_get_contents(INSTALL_PATH . $file), true); + } + + return $this->config = array_merge($defaults, $config); + } +} + +// Hijack HTTP requests to plugin assets e.g. service worker +pwa::http_request();