PWA plugin
19
plugins/pwa/README.md
Normal file
|
@ -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.
|
BIN
plugins/pwa/assets/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 2 KiB |
BIN
plugins/pwa/assets/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 4.3 KiB |
BIN
plugins/pwa/assets/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
plugins/pwa/assets/favicon-16x16.png
Normal file
After Width: | Height: | Size: 589 B |
BIN
plugins/pwa/assets/favicon-32x32.png
Normal file
After Width: | Height: | Size: 851 B |
BIN
plugins/pwa/assets/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
28
plugins/pwa/assets/manifest.json
Normal file
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
1
plugins/pwa/assets/safari-pinned-tab.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg version="1" xmlns="http://www.w3.org/2000/svg" width="933.333" height="933.333" viewBox="0 0 700.000000 700.000000"><path d="M326.5 1.1c-29.8 3-57.3 11-84.5 24.4-49.5 24.3-91.8 68.7-113.4 119-2.1 4.9-4.5 10.3-5.2 12-1.6 3.8-8.1 25.3-9.5 31.4-1.3 5.6-2.8 14.2-3.5 19.6-.3 2.2-.7 4.9-.9 6-.2 1.1-.4 2.8-.4 3.7-.1 1-3.7 3.8-9.3 7.1-9.1 5.4-10.8 6.3-23.6 13.6-3.4 2-14.4 8.3-24.3 14.1-10 5.8-21 12.1-24.5 14.1-3.6 1.9-7 4.1-7.6 4.7-.7.7-1.5 1.2-1.9 1.2-.4 0-4.6 2.3-9.3 5.1l-8.6 5v215.7l6.1 3.6c3.3 2 6.2 3.6 6.5 3.6.2 0 2.1 1.1 4.2 2.3 2 1.3 5.7 3.5 8.2 4.9 2.5 1.4 13.2 7.6 23.8 13.7 10.6 6.1 20.7 12 22.5 13 1.7 1 17.4 10 34.7 20.1 17.3 10.1 33.1 19.2 35 20.3 1.9 1 15 8.6 29 16.7s27.1 15.7 29 16.8c10.1 5.7 50.3 29 55.5 32.2 3.4 2 6.9 3.9 7.8 4.3.9.3 4.2 2.2 7.2 4.2 3.1 1.9 5.7 3.5 5.9 3.5.3 0 3.7 1.9 7.8 4.3 4 2.4 9.3 5.5 11.8 6.9 2.5 1.4 14.4 8.3 26.5 15.3s23.5 13.6 25.3 14.6l3.3 1.9 12.2-7.1c6.7-4 14.2-8.3 16.7-9.7 4.4-2.5 9.6-5.5 13.5-7.9 1.1-.7 4.3-2.4 7-3.9 2.8-1.4 6.2-3.5 7.7-4.5 1.4-1.1 2.9-1.9 3.2-1.9.3 0 3.4-1.7 6.8-3.8 3.5-2.1 8.3-5 10.8-6.4 3.5-2 36.6-21 66.7-38.4 44.5-25.8 40.4-23.4 67.8-39.2 11-6.3 29.7-17.1 41.5-23.9 11.8-6.9 27.8-16.1 35.5-20.6 7.7-4.4 19.2-11.1 25.5-14.8 6.3-3.6 13.1-7.6 15.1-8.7 1.9-1.1 7.2-4.1 11.7-6.7l8.2-4.8-.2-108-.3-107.9-20.5-11.9c-11.3-6.5-22.7-13.2-25.5-14.8-2.7-1.6-6.6-3.8-8.5-4.9-1.9-1.1-14.7-8.4-28.5-16.4l-24.9-14.5-1.8-11.9c-7.6-51.5-31.1-98.5-67.3-134.9-14.2-14.3-18.9-18.3-34.1-29-30-21.1-65.3-35.3-101.9-41-11.9-1.9-47.5-2.7-60-1.4z"/></svg>
|
After Width: | Height: | Size: 1.5 KiB |
29
plugins/pwa/composer.json
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
101
plugins/pwa/js/pwa.js
Normal file
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* PWA plugin engine
|
||||
*
|
||||
* @author Christian Mollekopf <mollekopf@kolabsys.com>
|
||||
*
|
||||
* @licstart The following is the entire license notice for the
|
||||
* JavaScript code in this file.
|
||||
*
|
||||
* Copyright (C) 2019, Kolab Systems AG <contact@kolabsys.com>
|
||||
*
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
100
plugins/pwa/js/sw.js
Normal file
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* PWA service worker
|
||||
*
|
||||
* @author Christian Mollekopf <mollekopf@kolabsys.com>
|
||||
*
|
||||
* @licstart The following is the entire license notice for the
|
||||
* JavaScript code in this file.
|
||||
*
|
||||
* Copyright (C) 2019, Kolab Systems AG <contact@kolabsys.com>
|
||||
*
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* @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');
|
||||
}
|
||||
});
|
||||
});
|
184
plugins/pwa/pwa.php
Normal file
|
@ -0,0 +1,184 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* "Converts" Roundcube into a Progressive Web Application
|
||||
*
|
||||
* @author Aleksander Machniak <machniak@kolabsys.com>
|
||||
* @author Christian Mollekopf <mollekopf@kolabsys.com>
|
||||
*
|
||||
* Copyright (C) 2019, Kolab Systems AG <contact@kolabsys.com>
|
||||
*
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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 <link> 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(
|
||||
'/(<link rel="shortcut icon" href=")([^"]+)/',
|
||||
'\\1' . $this->urlbase . 'assets/favicon.ico',
|
||||
$args['content']
|
||||
);
|
||||
|
||||
return $args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds <meta> 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();
|