mirror of
https://github.com/OCA/web.git
synced 2025-02-22 13:21:25 +02:00
@@ -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 <https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification>`_
|
||||
* Integrate `Web Share API <https://web.dev/web-share/>`_
|
||||
* 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 <https://tecnativa.com>`_:
|
||||
|
||||
* Alexandre D. Díaz
|
||||
* João Marques
|
||||
|
||||
Maintainers
|
||||
~~~~~~~~~~~
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
from . import controllers
|
||||
from . import models
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
# Copyright 2020 Lorenzo Battistini @ TAKOBI
|
||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
|
||||
from . import main
|
||||
|
||||
@@ -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")],
|
||||
)
|
||||
|
||||
1
web_pwa_oca/models/__init__.py
Normal file
1
web_pwa_oca/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import res_config_settings
|
||||
162
web_pwa_oca/models/res_config_settings.py
Normal file
162
web_pwa_oca/models/res_config_settings.py
Normal file
@@ -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
|
||||
)
|
||||
@@ -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**
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
* `TAKOBI <https://takobi.online>`_:
|
||||
|
||||
* Lorenzo Battistini
|
||||
|
||||
* `Tecnativa <https://tecnativa.com>`_:
|
||||
|
||||
* Alexandre D. Díaz
|
||||
* João Marques
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification>`_
|
||||
* Integrate `Web Share API <https://web.dev/web-share/>`_
|
||||
* 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.
|
||||
|
||||
4
web_pwa_oca/readme/USAGE.rst
Normal file
4
web_pwa_oca/readme/USAGE.rst
Normal file
@@ -0,0 +1,4 @@
|
||||
To use your PWA:
|
||||
|
||||
#. Open the Odoo web app using a supported browser (like Chrome/Chromium)
|
||||
#. Install the PWA
|
||||
@@ -372,17 +372,26 @@ ul.auto-toc {
|
||||
<p>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.</p>
|
||||
<ul class="simple">
|
||||
<li>Developers Info.</li>
|
||||
</ul>
|
||||
<p>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.</p>
|
||||
<p>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’.</p>
|
||||
<p>The purpose of this module is give a base to make PWA applications.</p>
|
||||
<p><strong>Table of contents</strong></p>
|
||||
<div class="contents local topic" id="contents">
|
||||
<ul class="simple">
|
||||
<li><a class="reference internal" href="#installation" id="id1">Installation</a></li>
|
||||
<li><a class="reference internal" href="#configuration" id="id2">Configuration</a></li>
|
||||
<li><a class="reference internal" href="#known-issues-roadmap" id="id3">Known issues / Roadmap</a></li>
|
||||
<li><a class="reference internal" href="#bug-tracker" id="id4">Bug Tracker</a></li>
|
||||
<li><a class="reference internal" href="#credits" id="id5">Credits</a><ul>
|
||||
<li><a class="reference internal" href="#authors" id="id6">Authors</a></li>
|
||||
<li><a class="reference internal" href="#contributors" id="id7">Contributors</a></li>
|
||||
<li><a class="reference internal" href="#maintainers" id="id8">Maintainers</a></li>
|
||||
<li><a class="reference internal" href="#usage" id="id3">Usage</a></li>
|
||||
<li><a class="reference internal" href="#known-issues-roadmap" id="id4">Known issues / Roadmap</a></li>
|
||||
<li><a class="reference internal" href="#bug-tracker" id="id5">Bug Tracker</a></li>
|
||||
<li><a class="reference internal" href="#credits" id="id6">Credits</a><ul>
|
||||
<li><a class="reference internal" href="#authors" id="id7">Authors</a></li>
|
||||
<li><a class="reference internal" href="#contributors" id="id8">Contributors</a></li>
|
||||
<li><a class="reference internal" href="#maintainers" id="id9">Maintainers</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -406,30 +415,77 @@ And like all other installed apps, it’s a top level app in the task switcher.<
|
||||
</div>
|
||||
<div class="section" id="configuration">
|
||||
<h1><a class="toc-backref" href="#id2">Configuration</a></h1>
|
||||
<p>The following system parameters con be set to customize the appearance of the application</p>
|
||||
<p>This module allows you to set the following parameters under settings to customize the appearance of the application</p>
|
||||
<ul class="simple">
|
||||
<li>pwa.manifest.name (defaults to “Odoo PWA”)</li>
|
||||
<li>pwa.manifest.short_name (defaults to “Odoo PWA”)</li>
|
||||
<li>pwa.manifest.icon128x128 (defaults to “/web_pwa_oca/static/img/icons/icon-128x128.png”)</li>
|
||||
<li>pwa.manifest.icon144x144 (defaults to “/web_pwa_oca/static/img/icons/icon-144x144.png”)</li>
|
||||
<li>pwa.manifest.icon152x152 (defaults to “/web_pwa_oca/static/img/icons/icon-152x152.png”)</li>
|
||||
<li>pwa.manifest.icon192x192 (defaults to “/web_pwa_oca/static/img/icons/icon-192x192.png”)</li>
|
||||
<li>pwa.manifest.icon256x256 (defaults to “/web_pwa_oca/static/img/icons/icon-256x256.png”)</li>
|
||||
<li>pwa.manifest.icon512x512 (defaults to “/web_pwa_oca/static/img/icons/icon-512x512.png”)</li>
|
||||
<li>PWA Name (defaults to “Odoo PWA”)</li>
|
||||
<li>PWA Short Name (defaults to “Odoo PWA”)</li>
|
||||
<li>PWA Icon (<strong>SVG</strong>) (defaults to “/web_pwa_oca/static/img/icons/odoo-logo.svg”)</li>
|
||||
</ul>
|
||||
<p>To configure your PWA:</p>
|
||||
<ol class="arabic simple">
|
||||
<li>Go to <strong>Settings > General Settings > Progressive Web App</strong>.</li>
|
||||
<li>Set the parameters (<em>Note:</em> Icon <strong>must be a SVG file</strong>)</li>
|
||||
<li><strong>Save</strong></li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="section" id="usage">
|
||||
<h1><a class="toc-backref" href="#id3">Usage</a></h1>
|
||||
<p>To use your PWA:</p>
|
||||
<ol class="arabic simple">
|
||||
<li>Open the Odoo web app using a supported browser (like Chrome/Chromium)</li>
|
||||
<li>Install the PWA</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="section" id="known-issues-roadmap">
|
||||
<h1><a class="toc-backref" href="#id3">Known issues / Roadmap</a></h1>
|
||||
<ul class="simple">
|
||||
<li>Evaluate to extend <tt class="docutils literal">FILES_TO_CACHE</tt></li>
|
||||
<li>Evaluate to use a normal JS file for service worker and download data from a normal JSON controller</li>
|
||||
<li>Integrate <a class="reference external" href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification">Notification API</a></li>
|
||||
<li>Integrate <a class="reference external" href="https://web.dev/web-share/">Web Share API</a></li>
|
||||
<li>Create <tt class="docutils literal">portal_pwa</tt> module, intended to be used by front-end users (customers, suppliers…)</li>
|
||||
<h1><a class="toc-backref" href="#id4">Known issues / Roadmap</a></h1>
|
||||
<ul>
|
||||
<li><p class="first">Integrate <a class="reference external" href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification">Notification API</a></p>
|
||||
</li>
|
||||
<li><p class="first">Integrate <a class="reference external" href="https://web.dev/web-share/">Web Share API</a></p>
|
||||
</li>
|
||||
<li><p class="first">Create <tt class="docutils literal">portal_pwa</tt> module, intended to be used by front-end users (customers, suppliers…)</p>
|
||||
</li>
|
||||
<li><p class="first">Current <em>John Resig’s inheritance</em> implementation doesn’t support <tt class="docutils literal">async</tt>
|
||||
functions because <tt class="docutils literal">this._super</tt> can’t be called inside a promise. So we
|
||||
need to use the following workaround:</p>
|
||||
<ul>
|
||||
<li><p class="first">Natural ‘async/await’ example (This breaks “_super” call):</p>
|
||||
<pre class="code javascript literal-block">
|
||||
<span class="kd">var</span> <span class="nx">MyClass</span> <span class="o">=</span> <span class="nx">OdooClass</span><span class="p">.</span><span class="nx">extend</span><span class="p">({</span>
|
||||
<span class="nx">myFunc</span><span class="o">:</span> <span class="nx">async</span> <span class="kd">function</span><span class="p">()</span> <span class="p">{</span>
|
||||
<span class="kr">const</span> <span class="nx">mydata</span> <span class="o">=</span> <span class="nx">await</span> <span class="p">...</span><span class="k">do</span> <span class="nx">await</span> <span class="nx">stuff</span><span class="p">...</span>
|
||||
<span class="k">return</span> <span class="nx">mydata</span><span class="p">;</span>
|
||||
<span class="p">}</span>
|
||||
<span class="p">});</span>
|
||||
</pre>
|
||||
</li>
|
||||
<li><p class="first">Same code with the workaround:</p>
|
||||
<pre class="code javascript literal-block">
|
||||
<span class="kd">var</span> <span class="nx">MyClass</span> <span class="o">=</span> <span class="nx">OdooClass</span><span class="p">.</span><span class="nx">extend</span><span class="p">({</span>
|
||||
<span class="nx">myFunc</span><span class="o">:</span> <span class="kd">function</span><span class="p">()</span> <span class="p">{</span>
|
||||
<span class="k">return</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">async</span> <span class="p">(</span><span class="nx">resolve</span><span class="p">,</span> <span class="nx">reject</span><span class="p">)</span> <span class="p">=></span> <span class="p">{</span>
|
||||
<span class="kr">const</span> <span class="nx">mydata</span> <span class="o">=</span> <span class="nx">await</span> <span class="p">...</span><span class="k">do</span> <span class="nx">await</span> <span class="nx">stuff</span><span class="p">...</span>
|
||||
<span class="k">return</span> <span class="nx">resolve</span><span class="p">(</span><span class="nx">mydata</span><span class="p">);</span>
|
||||
<span class="p">});</span>
|
||||
<span class="p">}</span>
|
||||
<span class="p">});</span>
|
||||
</pre>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><p class="first">Fix issue when trying to run in localhost with several databases. The browser
|
||||
doesn’t send the cookie and web manifest returns 404.</p>
|
||||
</li>
|
||||
<li><p class="first">Evaluate to support ‘require’ system.</p>
|
||||
</li>
|
||||
<li><p class="first">‘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.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="bug-tracker">
|
||||
<h1><a class="toc-backref" href="#id4">Bug Tracker</a></h1>
|
||||
<h1><a class="toc-backref" href="#id5">Bug Tracker</a></h1>
|
||||
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/web/issues">GitHub Issues</a>.
|
||||
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
|
||||
<p>Do not contact contributors directly about support or help with technical issues.</p>
|
||||
</div>
|
||||
<div class="section" id="credits">
|
||||
<h1><a class="toc-backref" href="#id5">Credits</a></h1>
|
||||
<h1><a class="toc-backref" href="#id6">Credits</a></h1>
|
||||
<div class="section" id="authors">
|
||||
<h2><a class="toc-backref" href="#id6">Authors</a></h2>
|
||||
<h2><a class="toc-backref" href="#id7">Authors</a></h2>
|
||||
<ul class="simple">
|
||||
<li>TAKOBI</li>
|
||||
<li>Tecnativa</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="contributors">
|
||||
<h2><a class="toc-backref" href="#id7">Contributors</a></h2>
|
||||
<h2><a class="toc-backref" href="#id8">Contributors</a></h2>
|
||||
<ul class="simple">
|
||||
<li><a class="reference external" href="https://takobi.online">TAKOBI</a>:<ul>
|
||||
<li>Lorenzo Battistini</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a class="reference external" href="https://tecnativa.com">Tecnativa</a>:<ul>
|
||||
<li>Alexandre D. Díaz</li>
|
||||
<li>João Marques</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="maintainers">
|
||||
<h2><a class="toc-backref" href="#id8">Maintainers</a></h2>
|
||||
<h2><a class="toc-backref" href="#id9">Maintainers</a></h2>
|
||||
<p>This module is maintained by the OCA.</p>
|
||||
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
|
||||
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
|
||||
|
||||
1
web_pwa_oca/static/img/icons/odoo_logo.svg
Normal file
1
web_pwa_oca/static/img/icons/odoo_logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="919" height="495" viewBox="0 0 919 495"><g fill="none"><path fill="#8F8F8F" d="M695 346c-41.421 0-75-33.579-75-75s33.579-75 75-75 75 33.579 75 75-33.579 75-75 75zm0-31c24.3 0 44-19.7 44-44s-19.7-44-44-44-44 19.7-44 44 19.7 44 44 44zm-157 31c-41.421 0-75-33.579-75-75s33.579-75 75-75 75 33.579 75 75-33.579 75-75 75zm0-31c24.3 0 44-19.7 44-44s-19.7-44-44-44-44 19.7-44 44 19.7 44 44 44zm-82-45c0 41.935-33.592 76-75.009 76C339.575 346 306 312.005 306 270.07c0-41.936 30.5-74.07 74.991-74.07 16.442 0 31.647 3.496 44.007 12.58l.002-43.49c0-8.334 7.27-15.09 15.5-15.09 8.228 0 15.5 6.762 15.5 15.09V270zm-75 45c24.3 0 44-19.7 44-44s-19.7-44-44-44-44 19.7-44 44 19.7 44 44 44z"/><path fill="#875A7B" d="M224 346c-41.421 0-75-33.579-75-75s33.579-75 75-75 75 33.579 75 75-33.579 75-75 75zm0-31c24.3 0 44-19.7 44-44s-19.7-44-44-44-44 19.7-44 44 19.7 44 44 44z"/></g><script xmlns=""/><script xmlns="" type="text/javascript"/><script xmlns="" type="text/javascript"/></svg>
|
||||
|
After Width: | Height: | Size: 1012 B |
@@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
95
web_pwa_oca/static/src/js/pwa_manager.js
Normal file
95
web_pwa_oca/static/src/js/pwa_manager.js
Normal file
@@ -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;
|
||||
});
|
||||
20
web_pwa_oca/static/src/js/webclient.js
Normal file
20
web_pwa_oca/static/src/js/webclient.js
Normal file
@@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
150
web_pwa_oca/static/src/js/worker/libs/class.js
Normal file
150
web_pwa_oca/static/src/js/worker/libs/class.js
Normal file
@@ -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;
|
||||
};
|
||||
35
web_pwa_oca/static/src/js/worker/pwa.js
Normal file
35
web_pwa_oca/static/src/js/worker/pwa.js
Normal file
@@ -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();
|
||||
},
|
||||
|
||||
});
|
||||
25
web_pwa_oca/templates/assets.xml
Normal file
25
web_pwa_oca/templates/assets.xml
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="web_layout_pwa" name="Web layout PWA" inherit_id="web.layout">
|
||||
<xpath expr="//meta[@name='viewport']" position="after">
|
||||
<!-- Add link rel manifest -->
|
||||
<link rel="manifest" t-attf-href="/web_pwa_oca/manifest.webmanifest"/>
|
||||
<!-- Add iOS meta tags and icons -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black"/>
|
||||
<t t-set="pwa_name" t-value="request.env['ir.config_parameter'].sudo().get_param('pwa.manifest.name')"/>
|
||||
<meta name="apple-mobile-web-app-title" t-att-content="pwa_name"/>
|
||||
<link rel="apple-touch-icon" href="/web_pwa_oca/static/img/icons/icon-152x152.png"/>
|
||||
<!-- Add meta theme-color -->
|
||||
<t t-set="pwa_theme_color" t-value="request.env['ir.config_parameter'].sudo().get_param('pwa.manifest.theme_color')"/>
|
||||
<meta name="theme-color" t-att-content="pwa_theme_color" />
|
||||
</xpath>
|
||||
</template>
|
||||
<template id="assets_backend" name="web service worker assets" inherit_id="web.assets_backend">
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript" src="/web_pwa_oca/static/src/js/pwa_manager.js"></script>
|
||||
<script type="text/javascript" src="/web_pwa_oca/static/src/js/pwa_install.js"></script>
|
||||
<script type="text/javascript" src="/web_pwa_oca/static/src/js/webclient.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
39
web_pwa_oca/templates/service_worker.xml
Normal file
39
web_pwa_oca/templates/service_worker.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<template id="pwa_init" name="PWA Initialization">
|
||||
const oca_pwa = new PWA(<t t-esc="str(pwa_params)"/>);
|
||||
</template>
|
||||
<template id="pwa_core_events" name="PWA Core Events">
|
||||
<t t-set="evt_install">
|
||||
evt.waitUntil(oca_pwa.installWorker());
|
||||
self.skipWaiting();
|
||||
</t>
|
||||
self.addEventListener('install', evt => {
|
||||
console.log('[ServiceWorker] Installing...');
|
||||
<t t-raw="evt_install" />
|
||||
});
|
||||
|
||||
<!-- This is necessary to get PWA installable.
|
||||
Other modules can add logic using 'evt_fetch' -->
|
||||
<t t-set="evt_fetch" />
|
||||
self.addEventListener('fetch', evt => {
|
||||
<t t-raw="evt_fetch" />
|
||||
});
|
||||
|
||||
<t t-set="evt_activate">
|
||||
console.log('[ServiceWorker] Activating...');
|
||||
evt.waitUntil(oca_pwa.activateWorker());
|
||||
self.clients.claim();
|
||||
</t>
|
||||
self.addEventListener('activate', evt => {
|
||||
<t t-raw="evt_activate" />
|
||||
});
|
||||
</template>
|
||||
<template id="service_worker" name="PWA Service Worker">
|
||||
self.importScripts(...<t t-esc="str(pwa_scripts)" />);
|
||||
<t t-call="web_pwa_oca.pwa_init" />
|
||||
<t t-call="web_pwa_oca.pwa_core_events" />
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
1
web_pwa_oca/tests/__init__.py
Normal file
1
web_pwa_oca/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_web_pwa_oca_controller
|
||||
125
web_pwa_oca/tests/test_web_pwa_oca_controller.py
Normal file
125
web_pwa_oca/tests/test_web_pwa_oca_controller.py
Normal file
@@ -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()
|
||||
48
web_pwa_oca/views/res_config_settings_views.xml
Normal file
48
web_pwa_oca/views/res_config_settings_views.xml
Normal file
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.pwa</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base_setup.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<div id="emails" position='after'>
|
||||
<h2>Progressive Web App</h2>
|
||||
<div class="row mt16 o_settings_container" id="pwa_settings">
|
||||
<div class="col-12 col-lg-6 o_setting_box" id="domain_setting">
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="pwa_name" string="PWA Title"/>
|
||||
<span class="fa fa-lg fa-globe"/>
|
||||
<div class="text-muted">
|
||||
Name and icon of your PWA
|
||||
</div>
|
||||
<div class="content-group">
|
||||
<div class="row mt16">
|
||||
<label class="col-lg-3 o_light_label" string="Name" for="pwa_name"/>
|
||||
<field name="pwa_name"/>
|
||||
</div>
|
||||
<div class="row mt16">
|
||||
<label class="col-lg-3 o_light_label" string="Short Name" for="pwa_short_name"/>
|
||||
<field name="pwa_short_name"/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="col-lg-3 o_light_label" for="pwa_background_color"/>
|
||||
<field name="pwa_background_color"/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="col-lg-3 o_light_label" for="pwa_theme_color"/>
|
||||
<field name="pwa_theme_color"/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="col-lg-3 o_light_label" for="pwa_icon" />
|
||||
<field name="pwa_icon" widget="image" class="float-left oe_avatar"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -1,105 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="web_layout_pwa" name="Web layout PWA" inherit_id="web.layout">
|
||||
<xpath expr="//meta[@name='viewport']" position="after">
|
||||
<!-- Add link rel manifest -->
|
||||
<link rel="manifest" href="/web_pwa_oca/manifest.json"/>
|
||||
<!-- Add iOS meta tags and icons -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black"/>
|
||||
<meta name="apple-mobile-web-app-title" content="Odoo PWA"/>
|
||||
<link rel="apple-touch-icon" href="/web_pwa_oca/static/img/icons/icon-152x152.png"/>
|
||||
<!-- Add meta theme-color -->
|
||||
<meta name="theme-color" content="#2E69B5" />
|
||||
</xpath>
|
||||
</template>
|
||||
<template id="assets_backend" name="PWA assets" inherit_id="web.assets_backend">
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript" src="/web_pwa_oca/static/src/js/pwa_install.js"/>
|
||||
</xpath>
|
||||
</template>
|
||||
<template id="service_worker" name="PWA service worker">
|
||||
'use strict';
|
||||
const CACHE_NAME = '<t t-esc="pwa_cache_name"/>';
|
||||
const FILES_TO_CACHE = [
|
||||
<t t-foreach="pwa_files_to_cache" t-as="file_to_cache">
|
||||
'<t t-esc="file_to_cache"/>',
|
||||
</t>
|
||||
];
|
||||
self.addEventListener('install', function (evt) {
|
||||
console.log('[ServiceWorker] Install');
|
||||
evt.waitUntil(
|
||||
caches.open(CACHE_NAME).then(function (cache) {
|
||||
console.log('[ServiceWorker] Pre-caching offline page');
|
||||
return cache.addAll(FILES_TO_CACHE);
|
||||
})
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
self.addEventListener('activate', function(evt) {
|
||||
console.log('[ServiceWorker] Activate');
|
||||
evt.waitUntil(
|
||||
caches.keys().then(function(keyList) {
|
||||
return Promise.all(keyList.map(function(key) {
|
||||
if (key !== CACHE_NAME) {
|
||||
console.log('[ServiceWorker] Removing old cache', key);
|
||||
return caches.delete(key);
|
||||
}
|
||||
}));
|
||||
})
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
self.addEventListener('fetch', function(evt) {
|
||||
if (evt.request.cache === 'only-if-cached' && evt.request.mode !== 'same-origin') {
|
||||
return;
|
||||
}
|
||||
console.log('[ServiceWorker] Fetch', evt.request.url);
|
||||
evt.respondWith(
|
||||
caches.open(CACHE_NAME).then(function(cache) {
|
||||
return cache.match(evt.request)
|
||||
.then(function(response) {
|
||||
return response || fetch(evt.request);
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
</template>
|
||||
|
||||
<template id="manifest" name="PWA manifest">
|
||||
{
|
||||
"name": "<t t-esc="pwa_name"/>",
|
||||
"short_name": "<t t-esc="pwa_short_name"/>",
|
||||
"icons": [{
|
||||
"src": "<t t-esc="icon128x128"/>",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png"
|
||||
}, {
|
||||
"src": "<t t-esc="icon144x144"/>",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png"
|
||||
}, {
|
||||
"src": "<t t-esc="icon152x152"/>",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png"
|
||||
}, {
|
||||
"src": "<t t-esc="icon192x192"/>",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}, {
|
||||
"src": "<t t-esc="icon256x256"/>",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png"
|
||||
}, {
|
||||
"src": "<t t-esc="icon512x512"/>",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}],
|
||||
"start_url": "/web",
|
||||
"display": "standalone",
|
||||
"background_color": "<t t-esc="background_color"/>",
|
||||
"theme_color": "<t t-esc="theme_color"/>"
|
||||
}
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user