diff --git a/web_pwa_oca/README.rst b/web_pwa_oca/README.rst index bad281d58..5f4e2acc8 100644 --- a/web_pwa_oca/README.rst +++ b/web_pwa_oca/README.rst @@ -31,6 +31,17 @@ Progressive Web Apps provide an installable, app-like experience on desktop and They're web apps that are fast and reliable. And most importantly, they're web apps that work in any browser. If you're building a web app today, you're already on the path towards building a Progressive Web App. + ++ Developers Info. + +The service worker is contructed using 'Odoo Class' to have the same class inheritance behaviour that in the 'user pages'. Be noticed +that 'Odoo Bootstrap' is not supported so, you can't use 'require' here. + +All service worker content can be found in 'static/src/js/worker'. The management between 'user pages' and service worker is done in +'pwa_manager.js'. + +The purpose of this module is give a base to make PWA applications. + **Table of contents** .. contents:: @@ -63,25 +74,66 @@ In case you previously installed `web_pwa`, run the following steps with `odoo s Configuration ============= -The following system parameters con be set to customize the appearance of the application +This module allows you to set the following parameters under settings to customize the appearance of the application -* pwa.manifest.name (defaults to "Odoo PWA") -* pwa.manifest.short_name (defaults to "Odoo PWA") -* pwa.manifest.icon128x128 (defaults to "/web_pwa_oca/static/img/icons/icon-128x128.png") -* pwa.manifest.icon144x144 (defaults to "/web_pwa_oca/static/img/icons/icon-144x144.png") -* pwa.manifest.icon152x152 (defaults to "/web_pwa_oca/static/img/icons/icon-152x152.png") -* pwa.manifest.icon192x192 (defaults to "/web_pwa_oca/static/img/icons/icon-192x192.png") -* pwa.manifest.icon256x256 (defaults to "/web_pwa_oca/static/img/icons/icon-256x256.png") -* pwa.manifest.icon512x512 (defaults to "/web_pwa_oca/static/img/icons/icon-512x512.png") +* PWA Name (defaults to "Odoo PWA") +* PWA Short Name (defaults to "Odoo PWA") +* PWA Icon (**SVG**) (defaults to "/web_pwa_oca/static/img/icons/odoo-logo.svg") + +To configure your PWA: + +#. Go to **Settings > General Settings > Progressive Web App**. +#. Set the parameters (*Note:* Icon **must be a SVG file**) +#. **Save** + +Usage +===== + +To use your PWA: + +#. Open the Odoo web app using a supported browser (like Chrome/Chromium) +#. Install the PWA Known issues / Roadmap ====================== -* Evaluate to extend ``FILES_TO_CACHE`` -* Evaluate to use a normal JS file for service worker and download data from a normal JSON controller * Integrate `Notification API `_ * Integrate `Web Share API `_ * Create ``portal_pwa`` module, intended to be used by front-end users (customers, suppliers...) +* Current *John Resig's inheritance* implementation doesn't support ``async`` + functions because ``this._super`` can't be called inside a promise. So we + need to use the following workaround: + + - Natural 'async/await' example (This breaks "_super" call): + + .. code-block:: javascript + + var MyClass = OdooClass.extend({ + myFunc: async function() { + const mydata = await ...do await stuff... + return mydata; + } + }); + + - Same code with the workaround: + + .. code-block:: javascript + + var MyClass = OdooClass.extend({ + myFunc: function() { + return new Promise(async (resolve, reject) => { + const mydata = await ...do await stuff... + return resolve(mydata); + }); + } + }); + +* Fix issue when trying to run in localhost with several databases. The browser + doesn't send the cookie and web manifest returns 404. +* Evaluate to support 'require' system. +* 'Install PWA' menu option disappears even if not installed. This is due to the + very nature of service workers, so they are running including when you close + the page tabs. Bug Tracker =========== @@ -100,6 +152,7 @@ Authors ~~~~~~~ * TAKOBI +* Tecnativa Contributors ~~~~~~~~~~~~ @@ -108,6 +161,11 @@ Contributors * Lorenzo Battistini +* `Tecnativa `_: + + * Alexandre D. Díaz + * João Marques + Maintainers ~~~~~~~~~~~ diff --git a/web_pwa_oca/__init__.py b/web_pwa_oca/__init__.py index e046e49fb..91c5580fe 100644 --- a/web_pwa_oca/__init__.py +++ b/web_pwa_oca/__init__.py @@ -1 +1,2 @@ from . import controllers +from . import models diff --git a/web_pwa_oca/__manifest__.py b/web_pwa_oca/__manifest__.py index d23a0c373..3ef47e9fa 100644 --- a/web_pwa_oca/__manifest__.py +++ b/web_pwa_oca/__manifest__.py @@ -1,4 +1,6 @@ # Copyright 2020 Lorenzo Battistini @ TAKOBI +# Copyright 2020 Tecnativa - Alexandre D. Díaz +# Copyright 2020 Tecnativa - João Marques # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). { @@ -8,7 +10,7 @@ "development_status": "Beta", "category": "Website", "website": "https://github.com/OCA/web", - "author": "TAKOBI, Odoo Community Association (OCA)", + "author": "TAKOBI, Tecnativa, Odoo Community Association (OCA)", "maintainers": ["eLBati"], "license": "LGPL-3", "application": True, @@ -18,7 +20,9 @@ 'mail', ], "data": [ - "views/webclient_templates.xml", + "templates/assets.xml", + "templates/service_worker.xml", + "views/res_config_settings_views.xml", ], 'qweb': [ 'static/src/xml/pwa_install.xml', diff --git a/web_pwa_oca/controllers/__init__.py b/web_pwa_oca/controllers/__init__.py index 12a7e529b..1f371126f 100644 --- a/web_pwa_oca/controllers/__init__.py +++ b/web_pwa_oca/controllers/__init__.py @@ -1 +1,3 @@ +# Copyright 2020 Lorenzo Battistini @ TAKOBI +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). from . import main diff --git a/web_pwa_oca/controllers/main.py b/web_pwa_oca/controllers/main.py index 3254b648b..3ec650436 100644 --- a/web_pwa_oca/controllers/main.py +++ b/web_pwa_oca/controllers/main.py @@ -1,75 +1,120 @@ +# Copyright 2020 Lorenzo Battistini @ TAKOBI +# Copyright 2020 Tecnativa - Alexandre D. Díaz +# Copyright 2020 Tecnativa - João Marques +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +import json + from odoo.http import request, Controller, route class PWA(Controller): + def _get_pwa_scripts(self): + """Scripts to be imported in the service worker (Order is important)""" + return [ + "/web/static/lib/underscore/underscore.js", + "/web_pwa_oca/static/src/js/worker/libs/class.js", + "/web_pwa_oca/static/src/js/worker/pwa.js", + ] - def get_asset_urls(self, asset_xml_id): - qweb = request.env['ir.qweb'].sudo() - assets = qweb._get_asset_nodes(asset_xml_id, {}, True, True) - urls = [] - for asset in assets: - if asset[0] == 'link': - urls.append(asset[1]['href']) - if asset[0] == 'script': - urls.append(asset[1]['src']) - return urls + @route("/service-worker.js", type="http", auth="public") + def render_service_worker(self): + """Route to register the service worker in the 'main' scope ('/')""" + return request.render( + "web_pwa_oca.service_worker", + { + "pwa_scripts": self._get_pwa_scripts(), + "pwa_params": self._get_pwa_params(), + }, + headers=[("Content-Type", "text/javascript;charset=utf-8")], + ) - @route('/service-worker.js', type='http', auth="public") - def service_worker(self): - qweb = request.env['ir.qweb'].sudo() - urls = [] - urls.extend(self.get_asset_urls("web.assets_common")) - urls.extend(self.get_asset_urls("web.assets_backend")) - version_list = [] - for url in urls: - version_list.append(url.split('/')[3]) - cache_version = '-'.join(version_list) - mimetype = 'text/javascript;charset=utf-8' - content = qweb.render('web_pwa_oca.service_worker', { - 'pwa_cache_name': cache_version, - 'pwa_files_to_cache': urls, - }) - return request.make_response(content, [('Content-Type', mimetype)]) + def _get_pwa_params(self): + """Get javascript PWA class initialzation params""" + return {} - @route('/web_pwa_oca/manifest.json', type='http', auth="public") - def manifest(self): - qweb = request.env['ir.qweb'].sudo() - config_param = request.env['ir.config_parameter'].sudo() - pwa_name = config_param.get_param("pwa.manifest.name", "Odoo PWA") - pwa_short_name = config_param.get_param("pwa.manifest.short_name", "Odoo PWA") - icon128x128 = config_param.get_param( - "pwa.manifest.icon128x128", - "/web_pwa_oca/static/img/icons/icon-128x128.png") - icon144x144 = config_param.get_param( - "pwa.manifest.icon144x144", - "/web_pwa_oca/static/img/icons/icon-144x144.png") - icon152x152 = config_param.get_param( - "pwa.manifest.icon152x152", - "/web_pwa_oca/static/img/icons/icon-152x152.png") - icon192x192 = config_param.get_param( - "pwa.manifest.icon192x192", - "/web_pwa_oca/static/img/icons/icon-192x192.png") - icon256x256 = config_param.get_param( - "pwa.manifest.icon256x256", - "/web_pwa_oca/static/img/icons/icon-256x256.png") - icon512x512 = config_param.get_param( - "pwa.manifest.icon512x512", - "/web_pwa_oca/static/img/icons/icon-512x512.png") - background_color = config_param.get_param( - "pwa.manifest.background_color", "#2E69B5") - theme_color = config_param.get_param( - "pwa.manifest.theme_color", "#2E69B5") - mimetype = 'application/json;charset=utf-8' - content = qweb.render('web_pwa_oca.manifest', { - 'pwa_name': pwa_name, - 'pwa_short_name': pwa_short_name, - 'icon128x128': icon128x128, - 'icon144x144': icon144x144, - 'icon152x152': icon152x152, - 'icon192x192': icon192x192, - 'icon256x256': icon256x256, - 'icon512x512': icon512x512, - 'background_color': background_color, - 'theme_color': theme_color, - }) - return request.make_response(content, [('Content-Type', mimetype)]) + def _get_pwa_manifest_icons(self, pwa_icon): + icons = [] + if not pwa_icon: + for size in [ + (128, 128), + (144, 144), + (152, 152), + (192, 192), + (256, 256), + (512, 512), + ]: + icons.append( + { + "src": "/web_pwa_oca/static/img/icons/icon-%sx%s.png" + % (str(size[0]), str(size[1])), + "sizes": "%sx%s" % (str(size[0]), str(size[1])), + "type": "image/png", + } + ) + elif not pwa_icon.mimetype.startswith("image/svg"): + all_icons = ( + request.env["ir.attachment"] + .sudo() + .search( + [ + ("url", "like", "/web_pwa_oca/icon"), + ( + "url", + "not like", + "/web_pwa_oca/icon.", + ), # Get only resized icons + ] + ) + ) + for icon in all_icons: + icon_size_name = icon.url.split("/")[-1].lstrip("icon").split(".")[0] + icons.append( + { + "src": icon.url, + "sizes": icon_size_name, + "type": icon.mimetype, + } + ) + else: + icons = [ + { + "src": pwa_icon.url, + "sizes": "128x128 144x144 152x152 192x192 256x256 512x512", + "type": pwa_icon.mimetype, + } + ] + return icons + + def _get_pwa_manifest(self): + """Webapp manifest""" + config_param_sudo = request.env["ir.config_parameter"].sudo() + pwa_name = config_param_sudo.get_param("pwa.manifest.name", "Odoo PWA") + pwa_short_name = config_param_sudo.get_param( + "pwa.manifest.short_name", "Odoo PWA" + ) + pwa_icon = ( + request.env["ir.attachment"] + .sudo() + .search([("url", "like", "/web_pwa_oca/icon.")]) + ) + background_color = config_param_sudo.get_param( + "pwa.manifest.background_color", "#2E69B5" + ) + theme_color = config_param_sudo.get_param("pwa.manifest.theme_color", "#2E69B5") + return { + "name": pwa_name, + "short_name": pwa_short_name, + "icons": self._get_pwa_manifest_icons(pwa_icon), + "start_url": '/web', + "display": "standalone", + "background_color": background_color, + "theme_color": theme_color, + } + + @route("/web_pwa_oca/manifest.webmanifest", type="http", auth="public") + def pwa_manifest(self): + """Returns the manifest used to install the page as app""" + return request.make_response( + json.dumps(self._get_pwa_manifest()), + headers=[("Content-Type", "application/json;charset=utf-8")], + ) diff --git a/web_pwa_oca/models/__init__.py b/web_pwa_oca/models/__init__.py new file mode 100644 index 000000000..0deb68c46 --- /dev/null +++ b/web_pwa_oca/models/__init__.py @@ -0,0 +1 @@ +from . import res_config_settings diff --git a/web_pwa_oca/models/res_config_settings.py b/web_pwa_oca/models/res_config_settings.py new file mode 100644 index 000000000..ebb6ea548 --- /dev/null +++ b/web_pwa_oca/models/res_config_settings.py @@ -0,0 +1,162 @@ +# Copyright 2020 Tecnativa - João Marques +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +import sys +import base64 +import io + +from PIL import Image + +from odoo import api, exceptions, fields, models, _ +from odoo.tools.mimetypes import guess_mimetype + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + _pwa_icon_url_base = "/web_pwa_oca/icon" + + pwa_name = fields.Char( + "Progressive Web App Name", help="Name of the Progressive Web Application" + ) + pwa_short_name = fields.Char( + "Progressive Web App Short Name", + help="Short Name of the Progressive Web Application", + ) + pwa_icon = fields.Binary("Icon", readonly=False) + pwa_background_color = fields.Char("Background Color") + pwa_theme_color = fields.Char("Theme Color") + + @api.model + def get_values(self): + config_parameter_obj_sudo = self.env["ir.config_parameter"].sudo() + res = super(ResConfigSettings, self).get_values() + res["pwa_name"] = ( + config_parameter_obj_sudo.get_param( + "pwa.manifest.name", default="Odoo PWA") + ) + res["pwa_short_name"] = ( + config_parameter_obj_sudo.get_param( + "pwa.manifest.short_name", default="Odoo") + ) + pwa_icon_ir_attachment = ( + self.env["ir.attachment"] + .sudo() + .search([("url", "like", self._pwa_icon_url_base + ".")]) + ) + res["pwa_icon"] = ( + pwa_icon_ir_attachment.datas if pwa_icon_ir_attachment else False + ) + res["pwa_background_color"] = ( + config_parameter_obj_sudo.get_param( + "pwa.manifest.background_color", default="#2E69B5") + ) + res["pwa_theme_color"] = ( + config_parameter_obj_sudo.get_param( + "pwa.manifest.theme_color", default="#2E69B5") + ) + return res + + def _unpack_icon(self, icon): + # Wrap decoded_icon in BytesIO object + decoded_icon = base64.b64decode(icon) + icon_bytes = io.BytesIO(decoded_icon) + return Image.open(icon_bytes) + + def _write_icon_to_attachment(self, extension, mimetype, size=None): + url = self._pwa_icon_url_base + extension + icon = self.pwa_icon + # Resize image + if size: + image = self._unpack_icon(icon) + resized_image = image.resize(size) + icon_bytes_output = io.BytesIO() + resized_image.save(icon_bytes_output, format=extension.lstrip(".").upper()) + icon = base64.b64encode(icon_bytes_output.getvalue()) + url = "%s%sx%s%s" % ( + self._pwa_icon_url_base, + str(size[0]), + str(size[1]), + extension, + ) + # Retreive existing attachment + existing_attachment = ( + self.env["ir.attachment"].sudo().search([("url", "like", url)]) + ) + # Write values to ir_attachment + values = { + "datas": icon, + "db_datas": icon, + "url": url, + "name": url, + "type": "binary", + "mimetype": mimetype, + } + # Rewrite if exists, else create + if existing_attachment: + existing_attachment.sudo().write(values) + else: + self.env["ir.attachment"].sudo().create(values) + + @api.model + def set_values(self): + config_parameter_obj_sudo = self.env["ir.config_parameter"].sudo() + res = super(ResConfigSettings, self).set_values() + config_parameter_obj_sudo.set_param( + "pwa.manifest.name", self.pwa_name + ) + config_parameter_obj_sudo.set_param( + "pwa.manifest.short_name", self.pwa_short_name + ) + config_parameter_obj_sudo.set_param( + "pwa.manifest.background_color", self.pwa_background_color + ) + config_parameter_obj_sudo.set_param( + "pwa.manifest.theme_color", self.pwa_theme_color + ) + # Retrieve previous value for pwa_icon from ir_attachment + pwa_icon_ir_attachments = ( + self.env["ir.attachment"] + .sudo() + .search([("url", "like", self._pwa_icon_url_base)]) + ) + # Delete or ignore if no icon provided + if not self.pwa_icon: + if pwa_icon_ir_attachments: + pwa_icon_ir_attachments.unlink() + return res + # Fail if icon provided is larger than 2mb + if sys.getsizeof(self.pwa_icon) > 2196608: + raise exceptions.UserError( + _("You can't upload a file with more than 2 MB.") + ) + # Confirm if the pwa_icon binary content is an SVG or PNG + # and process accordingly + decoded_pwa_icon = base64.b64decode(self.pwa_icon) + # Full mimetype detection + pwa_icon_mimetype = guess_mimetype(decoded_pwa_icon) + pwa_icon_extension = "." + pwa_icon_mimetype.split("/")[-1].split("+")[0] + if not pwa_icon_mimetype.startswith( + "image/svg" + ) and not pwa_icon_mimetype.startswith("image/png"): + raise exceptions.UserError(_("You can only upload SVG or PNG files")) + # Delete all previous records if we are writting new ones + if pwa_icon_ir_attachments: + pwa_icon_ir_attachments.unlink() + self._write_icon_to_attachment(pwa_icon_extension, pwa_icon_mimetype) + # write multiple sizes if not SVG + if pwa_icon_extension != ".svg": + # Fail if provided PNG is smaller than 512x512 + if self._unpack_icon(self.pwa_icon).size < (512, 512): + raise exceptions.UserError( + _("You can only upload PNG files bigger than 512x512") + ) + for size in [ + (128, 128), + (144, 144), + (152, 152), + (192, 192), + (256, 256), + (512, 512), + ]: + self._write_icon_to_attachment( + pwa_icon_extension, pwa_icon_mimetype, size=size + ) diff --git a/web_pwa_oca/readme/CONFIGURE.rst b/web_pwa_oca/readme/CONFIGURE.rst index 9fd39a840..dbd0a83f7 100644 --- a/web_pwa_oca/readme/CONFIGURE.rst +++ b/web_pwa_oca/readme/CONFIGURE.rst @@ -1,10 +1,11 @@ -The following system parameters con be set to customize the appearance of the application +This module allows you to set the following parameters under settings to customize the appearance of the application -* pwa.manifest.name (defaults to "Odoo PWA") -* pwa.manifest.short_name (defaults to "Odoo PWA") -* pwa.manifest.icon128x128 (defaults to "/web_pwa_oca/static/img/icons/icon-128x128.png") -* pwa.manifest.icon144x144 (defaults to "/web_pwa_oca/static/img/icons/icon-144x144.png") -* pwa.manifest.icon152x152 (defaults to "/web_pwa_oca/static/img/icons/icon-152x152.png") -* pwa.manifest.icon192x192 (defaults to "/web_pwa_oca/static/img/icons/icon-192x192.png") -* pwa.manifest.icon256x256 (defaults to "/web_pwa_oca/static/img/icons/icon-256x256.png") -* pwa.manifest.icon512x512 (defaults to "/web_pwa_oca/static/img/icons/icon-512x512.png") +* PWA Name (defaults to "Odoo PWA") +* PWA Short Name (defaults to "Odoo PWA") +* PWA Icon (**SVG**) (defaults to "/web_pwa_oca/static/img/icons/odoo-logo.svg") + +To configure your PWA: + +#. Go to **Settings > General Settings > Progressive Web App**. +#. Set the parameters (*Note:* Icon **must be a SVG file**) +#. **Save** diff --git a/web_pwa_oca/readme/CONTRIBUTORS.rst b/web_pwa_oca/readme/CONTRIBUTORS.rst index 2b476d752..200dad8dc 100644 --- a/web_pwa_oca/readme/CONTRIBUTORS.rst +++ b/web_pwa_oca/readme/CONTRIBUTORS.rst @@ -1,3 +1,8 @@ * `TAKOBI `_: * Lorenzo Battistini + +* `Tecnativa `_: + + * Alexandre D. Díaz + * João Marques diff --git a/web_pwa_oca/readme/DESCRIPTION.rst b/web_pwa_oca/readme/DESCRIPTION.rst index e2eb74425..2b4b14ef4 100644 --- a/web_pwa_oca/readme/DESCRIPTION.rst +++ b/web_pwa_oca/readme/DESCRIPTION.rst @@ -3,3 +3,14 @@ Make Odoo an installable Progressive Web Application. Progressive Web Apps provide an installable, app-like experience on desktop and mobile that are built and delivered directly via the web. They're web apps that are fast and reliable. And most importantly, they're web apps that work in any browser. If you're building a web app today, you're already on the path towards building a Progressive Web App. + + ++ Developers Info. + +The service worker is contructed using 'Odoo Class' to have the same class inheritance behaviour that in the 'user pages'. Be noticed +that 'Odoo Bootstrap' is not supported so, you can't use 'require' here. + +All service worker content can be found in 'static/src/js/worker'. The management between 'user pages' and service worker is done in +'pwa_manager.js'. + +The purpose of this module is give a base to make PWA applications. diff --git a/web_pwa_oca/readme/ROADMAP.rst b/web_pwa_oca/readme/ROADMAP.rst index 9e763ae62..5d292a4e7 100644 --- a/web_pwa_oca/readme/ROADMAP.rst +++ b/web_pwa_oca/readme/ROADMAP.rst @@ -1,5 +1,37 @@ -* Evaluate to extend ``FILES_TO_CACHE`` -* Evaluate to use a normal JS file for service worker and download data from a normal JSON controller * Integrate `Notification API `_ * Integrate `Web Share API `_ * Create ``portal_pwa`` module, intended to be used by front-end users (customers, suppliers...) +* Current *John Resig's inheritance* implementation doesn't support ``async`` + functions because ``this._super`` can't be called inside a promise. So we + need to use the following workaround: + + - Natural 'async/await' example (This breaks "_super" call): + + .. code-block:: javascript + + var MyClass = OdooClass.extend({ + myFunc: async function() { + const mydata = await ...do await stuff... + return mydata; + } + }); + + - Same code with the workaround: + + .. code-block:: javascript + + var MyClass = OdooClass.extend({ + myFunc: function() { + return new Promise(async (resolve, reject) => { + const mydata = await ...do await stuff... + return resolve(mydata); + }); + } + }); + +* Fix issue when trying to run in localhost with several databases. The browser + doesn't send the cookie and web manifest returns 404. +* Evaluate to support 'require' system. +* 'Install PWA' menu option disappears even if not installed. This is due to the + very nature of service workers, so they are running including when you close + the page tabs. diff --git a/web_pwa_oca/readme/USAGE.rst b/web_pwa_oca/readme/USAGE.rst new file mode 100644 index 000000000..3cf386adc --- /dev/null +++ b/web_pwa_oca/readme/USAGE.rst @@ -0,0 +1,4 @@ +To use your PWA: + +#. Open the Odoo web app using a supported browser (like Chrome/Chromium) +#. Install the PWA diff --git a/web_pwa_oca/static/description/index.html b/web_pwa_oca/static/description/index.html index 7659b00f8..24b4b17bf 100644 --- a/web_pwa_oca/static/description/index.html +++ b/web_pwa_oca/static/description/index.html @@ -372,17 +372,26 @@ ul.auto-toc {

Progressive Web Apps provide an installable, app-like experience on desktop and mobile that are built and delivered directly via the web. They’re web apps that are fast and reliable. And most importantly, they’re web apps that work in any browser. If you’re building a web app today, you’re already on the path towards building a Progressive Web App.

+
    +
  • Developers Info.
  • +
+

The service worker is contructed using ‘Odoo Class’ to have the same class inheritance behaviour that in the ‘user pages’. Be noticed +that ‘Odoo Bootstrap’ is not supported so, you can’t use ‘require’ here.

+

All service worker content can be found in ‘static/src/js/worker’. The management between ‘user pages’ and service worker is done in +‘pwa_manager.js’.

+

The purpose of this module is give a base to make PWA applications.

Table of contents

Configuration

-

The following system parameters con be set to customize the appearance of the application

+

This module allows you to set the following parameters under settings to customize the appearance of the application

    -
  • pwa.manifest.name (defaults to “Odoo PWA”)
  • -
  • pwa.manifest.short_name (defaults to “Odoo PWA”)
  • -
  • pwa.manifest.icon128x128 (defaults to “/web_pwa_oca/static/img/icons/icon-128x128.png”)
  • -
  • pwa.manifest.icon144x144 (defaults to “/web_pwa_oca/static/img/icons/icon-144x144.png”)
  • -
  • pwa.manifest.icon152x152 (defaults to “/web_pwa_oca/static/img/icons/icon-152x152.png”)
  • -
  • pwa.manifest.icon192x192 (defaults to “/web_pwa_oca/static/img/icons/icon-192x192.png”)
  • -
  • pwa.manifest.icon256x256 (defaults to “/web_pwa_oca/static/img/icons/icon-256x256.png”)
  • -
  • pwa.manifest.icon512x512 (defaults to “/web_pwa_oca/static/img/icons/icon-512x512.png”)
  • +
  • PWA Name (defaults to “Odoo PWA”)
  • +
  • PWA Short Name (defaults to “Odoo PWA”)
  • +
  • PWA Icon (SVG) (defaults to “/web_pwa_oca/static/img/icons/odoo-logo.svg”)
+

To configure your PWA:

+
    +
  1. Go to Settings > General Settings > Progressive Web App.
  2. +
  3. Set the parameters (Note: Icon must be a SVG file)
  4. +
  5. Save
  6. +
+
+
+

Usage

+

To use your PWA:

+
    +
  1. Open the Odoo web app using a supported browser (like Chrome/Chromium)
  2. +
  3. Install the PWA
  4. +
-

Known issues / Roadmap

-
    -
  • Evaluate to extend FILES_TO_CACHE
  • -
  • Evaluate to use a normal JS file for service worker and download data from a normal JSON controller
  • -
  • Integrate Notification API
  • -
  • Integrate Web Share API
  • -
  • Create portal_pwa module, intended to be used by front-end users (customers, suppliers…)
  • +

    Known issues / Roadmap

    +
      +
    • Integrate Notification API

      +
    • +
    • Integrate Web Share API

      +
    • +
    • Create portal_pwa module, intended to be used by front-end users (customers, suppliers…)

      +
    • +
    • Current John Resig’s inheritance implementation doesn’t support async +functions because this._super can’t be called inside a promise. So we +need to use the following workaround:

      +
        +
      • Natural ‘async/await’ example (This breaks “_super” call):

        +
        +var MyClass = OdooClass.extend({
        +    myFunc: async function() {
        +        const mydata = await ...do await stuff...
        +        return mydata;
        +    }
        +});
        +
        +
      • +
      • Same code with the workaround:

        +
        +var MyClass = OdooClass.extend({
        +    myFunc: function() {
        +        return new Promise(async (resolve, reject) => {
        +            const mydata = await ...do await stuff...
        +            return resolve(mydata);
        +        });
        +    }
        +});
        +
        +
      • +
      +
    • +
    • Fix issue when trying to run in localhost with several databases. The browser +doesn’t send the cookie and web manifest returns 404.

      +
    • +
    • Evaluate to support ‘require’ system.

      +
    • +
    • ‘Install PWA’ menu option disappears even if not installed. This is due to the +very nature of service workers, so they are running including when you close +the page tabs.

      +
-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us smashing it by providing a detailed and welcomed @@ -437,24 +493,30 @@ If you spotted it first, help us smashing it by providing a detailed and welcome

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • TAKOBI
  • +
  • Tecnativa
-

Contributors

+

Contributors

  • TAKOBI:
    • Lorenzo Battistini
  • +
  • Tecnativa:
      +
    • Alexandre D. Díaz
    • +
    • João Marques
    • +
    +
-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association

OCA, or the Odoo Community Association, is a nonprofit organization whose diff --git a/web_pwa_oca/static/img/icons/odoo_logo.svg b/web_pwa_oca/static/img/icons/odoo_logo.svg new file mode 100644 index 000000000..9faea0fa5 --- /dev/null +++ b/web_pwa_oca/static/img/icons/odoo_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web_pwa_oca/static/src/js/pwa_install.js b/web_pwa_oca/static/src/js/pwa_install.js index 3a8b741fe..b8076988d 100644 --- a/web_pwa_oca/static/src/js/pwa_install.js +++ b/web_pwa_oca/static/src/js/pwa_install.js @@ -1,45 +1,40 @@ +/* Copyright 2020 Lorenzo Battistini @ TAKOBI + Copyright 2020 Tecnativa - Alexandre D. Díaz + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + odoo.define('web_pwa_oca.systray.install', function (require) { -"use strict"; + "use strict"; -var core = require('web.core'); -var session = require('web.session'); -var UserMenu = require('web.UserMenu'); + var UserMenu = require('web.UserMenu'); + var WebClientObj = require("web.web_client"); -if ('serviceWorker' in navigator) { - window.addEventListener('load', function () { - navigator.serviceWorker.register('/service-worker.js') - .then(function (reg) { - console.log('Service worker registered.', reg); - }); - }); -} -var deferredInstallPrompt = null; + UserMenu.include({ -UserMenu.include({ - start: function () { - window.addEventListener('beforeinstallprompt', this.saveBeforeInstallPromptEvent); - return this._super.apply(this, arguments); - }, - saveBeforeInstallPromptEvent: function(evt) { - deferredInstallPrompt = evt; - this.$.find('#pwa_install_button')[0].removeAttribute('hidden'); - }, - _onMenuInstallpwa: function () { - deferredInstallPrompt.prompt(); - // Hide the install button, it can't be called twice. - this.el.setAttribute('hidden', true); - // Log user response to prompt. - deferredInstallPrompt.userChoice - .then(function (choice) { - if (choice.outcome === 'accepted') { - console.log('User accepted the A2HS prompt', choice); - } else { - console.log('User dismissed the A2HS prompt', choice); - } - deferredInstallPrompt = null; + /** + * We can't control if the UserMenu is loaded berfore PWA manager... + * So check if need unhide the user menu options to install the PWA. + * + * @override + */ + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + if (WebClientObj.pwa_manager.canBeInstalled()) { + self.$el.find('#pwa_install_button')[0] + .removeAttribute('hidden'); + } }); - }, -}); + }, + + /** + * Handle 'Install PWA' user menu option click + * + * @private + */ + _onMenuInstallpwa: function () { + WebClientObj.pwa_manager.install(); + }, + }); }); diff --git a/web_pwa_oca/static/src/js/pwa_manager.js b/web_pwa_oca/static/src/js/pwa_manager.js new file mode 100644 index 000000000..6ffdc1871 --- /dev/null +++ b/web_pwa_oca/static/src/js/pwa_manager.js @@ -0,0 +1,95 @@ +/* Copyright 2020 Tecnativa - Alexandre D. Díaz + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +odoo.define("web_pwa_oca.PWAManager", function (require) { + "use strict"; + + var Widget = require("web.Widget"); + + + var PWAManager = Widget.extend({ + _deferredInstallPrompt: null, + + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + if (!('serviceWorker' in navigator)) { + throw new Error( + "This browser is not compatible with service workers"); + } + this._service_worker = navigator.serviceWorker; + this.registerServiceWorker('/service-worker.js'); + window.addEventListener( + 'beforeinstallprompt', this._onBeforeInstallPrompt.bind(this)); + }, + + /** + * @param {String} sw_script + * @returns {Promise} + */ + registerServiceWorker: function (sw_script) { + return this._service_worker.register(sw_script) + .then(this._onRegisterServiceWorker) + .catch(function (error) { + console.log('[ServiceWorker] Registration failed: ', error); + }); + }, + + install: function () { + if (!this._deferredInstallPrompt) { + return; + } + var self = this; + var systray_menu = this.getParent().menu.systray_menu; + this._deferredInstallPrompt.prompt(); + // Log user response to prompt. + this._deferredInstallPrompt.userChoice + .then(function (choice) { + if (choice.outcome === 'accepted') { + // Hide the install button, it can't be called twice. + systray_menu.$el.find('#pwa_install_button') + .attr('hidden', true); + self._deferredInstallPrompt = null; + console.log('User accepted the A2HS prompt', choice); + } else { + console.log('User dismissed the A2HS prompt', choice); + } + }); + }, + + canBeInstalled: function () { + return !_.isNull(this._deferredInstallPrompt); + }, + + /** + * Handle PWA installation flow + * + * @private + * @param {BeforeInstallPromptEvent} evt + */ + _onBeforeInstallPrompt: function (evt) { + evt.preventDefault(); + this._deferredInstallPrompt = evt; + // UserMenu can be loaded after this module + var menu = this.getParent().menu; + if (menu && menu.systray_menu) { + menu.systray_menu.$el.find('#pwa_install_button')[0] + .removeAttribute('hidden'); + } + }, + + /** + * Need register some extra API? override this! + * + * @private + * @param {ServiceWorkerRegistration} registration + */ + _onRegisterServiceWorker: function (registration) { + console.log('[ServiceWorker] Registered:', registration); + }, + }); + + return PWAManager; +}); diff --git a/web_pwa_oca/static/src/js/webclient.js b/web_pwa_oca/static/src/js/webclient.js new file mode 100644 index 000000000..8bff98597 --- /dev/null +++ b/web_pwa_oca/static/src/js/webclient.js @@ -0,0 +1,20 @@ +/* Copyright 2020 Tecnativa - Alexandre D. Díaz + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +odoo.define("web_pwa_oca.webclient", function (require) { + "use strict"; + + var WebClient = require("web.WebClient"); + var PWAManager = require("web_pwa_oca.PWAManager"); + + WebClient.include({ + + /** + * @override + */ + show_application: function () { + this.pwa_manager = new PWAManager(this); + return this._super.apply(this, arguments); + }, + }); +}); diff --git a/web_pwa_oca/static/src/js/worker/libs/class.js b/web_pwa_oca/static/src/js/worker/libs/class.js new file mode 100644 index 000000000..c44c8e715 --- /dev/null +++ b/web_pwa_oca/static/src/js/worker/libs/class.js @@ -0,0 +1,150 @@ +/** + * Improved John Resig's inheritance, based on: + * + * Simple JavaScript Inheritance + * By John Resig http://ejohn.org/ + * MIT Licensed. + * + * Adds "include()" + * + * Defines The Class object. That object can be used to define and inherit classes using + * the extend() method. + * + * Example:: + * + * var Person = Class.extend({ + * init: function(isDancing){ + * this.dancing = isDancing; + * }, + * dance: function(){ + * return this.dancing; + * } + * }); + * + * The init() method act as a constructor. This class can be instanced this way:: + * + * var person = new Person(true); + * person.dance(); + * + * The Person class can also be extended again: + * + * var Ninja = Person.extend({ + * init: function(){ + * this._super( false ); + * }, + * dance: function(){ + * // Call the inherited version of dance() + * return this._super(); + * }, + * swingSword: function(){ + * return true; + * } + * }); + * + * When extending a class, each re-defined method can use this._super() to call the previous + * implementation of that method. + * + * @class Class + */ +function OdooClass(){} + +var initializing = false; +var fnTest = /xyz/.test(function(){xyz();}) ? /\b_super\b/ : /.*/; + +/** + * Subclass an existing class + * + * @param {Object} prop class-level properties (class attributes and instance methods) to set on the new class + */ +OdooClass.extend = function() { + var _super = this.prototype; + // Support mixins arguments + var args = _.toArray(arguments); + args.unshift({}); + var prop = _.extend.apply(_,args); + + // Instantiate a web class (but only create the instance, + // don't run the init constructor) + initializing = true; + var This = this; + var prototype = new This(); + initializing = false; + + // Copy the properties over onto the new prototype + _.each(prop, function(val, name) { + // Check if we're overwriting an existing function + prototype[name] = typeof prop[name] == "function" && + fnTest.test(prop[name]) ? + (function(name, fn) { + return function() { + var tmp = this._super; + + // Add a new ._super() method that is the same + // method but on the super-class + this._super = _super[name]; + + // The method only need to be bound temporarily, so + // we remove it when we're done executing + var ret = fn.apply(this, arguments); + this._super = tmp; + + return ret; + }; + })(name, prop[name]) : + prop[name]; + }); + + // The dummy class constructor + function Class() { + if(this.constructor !== OdooClass){ + throw new Error("You can only instanciate objects with the 'new' operator"); + } + // All construction is actually done in the init method + this._super = null; + if (!initializing && this.init) { + var ret = this.init.apply(this, arguments); + if (ret) { return ret; } + } + return this; + } + Class.include = function (properties) { + _.each(properties, function(val, name) { + if (typeof properties[name] !== 'function' + || !fnTest.test(properties[name])) { + prototype[name] = properties[name]; + } else if (typeof prototype[name] === 'function' + && prototype.hasOwnProperty(name)) { + prototype[name] = (function (name, fn, previous) { + return function () { + var tmp = this._super; + this._super = previous; + var ret = fn.apply(this, arguments); + this._super = tmp; + return ret; + }; + })(name, properties[name], prototype[name]); + } else if (typeof _super[name] === 'function') { + prototype[name] = (function (name, fn) { + return function () { + var tmp = this._super; + this._super = _super[name]; + var ret = fn.apply(this, arguments); + this._super = tmp; + return ret; + }; + })(name, properties[name]); + } + }); + }; + + // Populate our constructed prototype object + Class.prototype = prototype; + + // Enforce the constructor to be what we expect + Class.constructor = Class; + + // And make this class extendable + Class.extend = this.extend; + + return Class; +}; diff --git a/web_pwa_oca/static/src/js/worker/pwa.js b/web_pwa_oca/static/src/js/worker/pwa.js new file mode 100644 index 000000000..d5079d622 --- /dev/null +++ b/web_pwa_oca/static/src/js/worker/pwa.js @@ -0,0 +1,35 @@ +"use strict"; +/* eslint strict: ["error", "global"] */ +/* eslint-disable no-undef, no-empty-function, no-implicit-globals, +no-unused-vars */ +/* Copyright 2020 Tecnativa - Alexandre D. Díaz + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +/** + * Services workers are a piece of software separated from the user page. + * Here can't use 'Odoo Bootstrap', so we can't work with 'require' system. + * When the service worker is called to be installed from the "pwa_manager" + * this class is instantiated. + */ +var PWA = OdooClass.extend({ + + // eslint-disable-next-line + init: function (params) { + // To be overridden + }, + + /** + * @returns {Promise} + */ + installWorker: function () { + return Promise.resolve(); + }, + + /** + * @returns {Promise} + */ + activateWorker: function () { + return Promise.resolve(); + }, + +}); diff --git a/web_pwa_oca/templates/assets.xml b/web_pwa_oca/templates/assets.xml new file mode 100644 index 000000000..e9c91c664 --- /dev/null +++ b/web_pwa_oca/templates/assets.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/web_pwa_oca/templates/service_worker.xml b/web_pwa_oca/templates/service_worker.xml new file mode 100644 index 000000000..888dc20ae --- /dev/null +++ b/web_pwa_oca/templates/service_worker.xml @@ -0,0 +1,39 @@ + + + + + + + + diff --git a/web_pwa_oca/tests/__init__.py b/web_pwa_oca/tests/__init__.py new file mode 100644 index 000000000..eeacc78da --- /dev/null +++ b/web_pwa_oca/tests/__init__.py @@ -0,0 +1 @@ +from . import test_web_pwa_oca_controller diff --git a/web_pwa_oca/tests/test_web_pwa_oca_controller.py b/web_pwa_oca/tests/test_web_pwa_oca_controller.py new file mode 100644 index 000000000..cbf683f5f --- /dev/null +++ b/web_pwa_oca/tests/test_web_pwa_oca_controller.py @@ -0,0 +1,125 @@ +# Copyright 2020 João Marques +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import json +import base64 + +import odoo.tests + +from odoo import exceptions +from odoo.modules.module import get_resource_path + + +class TestUi(odoo.tests.HttpCase): + def setUp(self): + super().setUp() + self.user = self.env.ref("base.user_admin") + self.res_config_settings_obj = ( + self.env["res.config.settings"].sudo(self.user.id).create({}) + ) + + def test_manifest_valid_json(self): + # Call the manifest controller + manifest_data = self.url_open("/web_pwa_oca/manifest.webmanifest") + # should be valid json + manifest_content_str = manifest_data.content.decode("utf-8") + json.loads(manifest_content_str) + + def test_manifest_correct_paramenters(self): + # Set PWA parameters in settings + self.res_config_settings_obj.pwa_name = "Test PWA" + self.res_config_settings_obj.pwa_short_name = "Test" + # icon should remain the default one + self.res_config_settings_obj.pwa_icon = False + self.res_config_settings_obj.set_values() + + # Call the manifest controller + manifest_data = self.url_open("/web_pwa_oca/manifest.webmanifest") + manifest_content_str = manifest_data.content.decode("utf-8") + manifest_content = json.loads(manifest_content_str) + + self.assertEquals(manifest_content["name"], "Test PWA") + self.assertEquals(manifest_content["short_name"], "Test") + # icon should remain the default one + self.assertEquals( + manifest_content["icons"][0]["src"], + "/web_pwa_oca/static/img/icons/icon-128x128.png", + ) + self.assertEquals(manifest_content["icons"][0]["sizes"], "128x128") + self.assertTrue(manifest_content["icons"][0]["type"].startswith("image/png")) + + def test_manifest_logo_upload(self): + with open( + "%s/static/img/icons/odoo_logo.svg" % get_resource_path("web_pwa_oca"), + "rb" + ) as fi: + icon_to_send = base64.b64encode(fi.read()) + + # Set PWA icon in settings + self.res_config_settings_obj.pwa_icon = icon_to_send + self.res_config_settings_obj.set_values() + + # Call the manifest controller + manifest_data = self.url_open("/web_pwa_oca/manifest.webmanifest") + manifest_content_str = manifest_data.content.decode("utf-8") + manifest_content = json.loads(manifest_content_str) + + self.assertEquals(manifest_content["icons"][0]["src"], "/web_pwa_oca/icon.svg") + self.assertTrue(manifest_content["icons"][0]["type"].startswith("image/svg")) + self.assertEquals( + manifest_content["icons"][0]["sizes"], + "128x128 144x144 152x152 192x192 256x256 512x512") + + # Get the icon and compare it + icon_data = self.url_open("/web_pwa_oca/icon.svg") + icon_data_bytes = base64.b64encode(icon_data.content) + self.assertEquals(icon_data_bytes, icon_to_send) + + def test_png_logo_upload(self): + with open( + "%s/static/img/icons/icon-512x512.png" % get_resource_path("web_pwa_oca"), + "rb" + ) as fi: + icon_to_send = base64.b64encode(fi.read()) + + # Set PWA icon in settings + self.res_config_settings_obj.pwa_icon = icon_to_send + self.res_config_settings_obj.set_values() + + # Call the manifest controller + manifest_data = self.url_open("/web_pwa_oca/manifest.webmanifest") + manifest_content_str = manifest_data.content.decode("utf-8") + manifest_content = json.loads(manifest_content_str) + + expected_vals = { + "src": "/web_pwa_oca/icon512x512.png", + "sizes": "512x512", + "type": "image/png", + } + self.assertTrue(expected_vals in manifest_content["icons"]) + + def test_manifest_logo_upload_big(self): + # Set PWA icon in settings + with self.assertRaises(exceptions.UserError): + # Image with more than 2MB + self.res_config_settings_obj.pwa_icon = b"a" * 3000000 + self.res_config_settings_obj.set_values() + + def test_manifest_logo_upload_extension(self): + with self.assertRaises(exceptions.UserError): + # Image that is not SVG or PNG + self.res_config_settings_obj.pwa_icon = b"a" * 1000 + self.res_config_settings_obj.set_values() + + def test_manifest_logo_upload_small(self): + icon_to_send = None + with open( + "%s/static/img/icons/icon-128x128.png" % get_resource_path("web_pwa_oca"), + "rb" + ) as fi: + icon_to_send = base64.b64encode(fi.read()) + # Set PWA icon in settings + with self.assertRaises(exceptions.UserError): + # Image smaller than 512X512 + self.res_config_settings_obj.pwa_icon = icon_to_send + self.res_config_settings_obj.set_values() diff --git a/web_pwa_oca/views/res_config_settings_views.xml b/web_pwa_oca/views/res_config_settings_views.xml new file mode 100644 index 000000000..48b5bb4b1 --- /dev/null +++ b/web_pwa_oca/views/res_config_settings_views.xml @@ -0,0 +1,48 @@ + + + + + res.config.settings.view.form.pwa + res.config.settings + + +

+

Progressive Web App

+
+
+
+
+
+
+
+ + + + diff --git a/web_pwa_oca/views/webclient_templates.xml b/web_pwa_oca/views/webclient_templates.xml deleted file mode 100644 index 14dd5a7a1..000000000 --- a/web_pwa_oca/views/webclient_templates.xml +++ /dev/null @@ -1,105 +0,0 @@ - - - -