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();