Convert frontend to do client-side modern JS detection (#23618)

* Convert frontend to do client-side async pick

* Further cleanup

* Updated frontend to 20190502.0

* Fix template caching

* Remove es5 test

* Lint

* Update description
This commit is contained in:
Paulus Schoutsen 2019-05-02 13:59:24 -07:00 committed by GitHub
parent 7331eb1f71
commit 592e99947d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 61 additions and 165 deletions

View file

@ -3,7 +3,7 @@ import asyncio
import json import json
import logging import logging
import os import os
from urllib.parse import urlparse import pathlib
from aiohttp import web from aiohttp import web
import voluptuous as vol import voluptuous as vol
@ -11,7 +11,6 @@ import jinja2
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.http.view import HomeAssistantView
from homeassistant.components.http.const import KEY_AUTHENTICATED
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.config import find_config_file, load_yaml_config_file from homeassistant.config import find_config_file, load_yaml_config_file
from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED
@ -27,14 +26,13 @@ CONF_EXTRA_HTML_URL = 'extra_html_url'
CONF_EXTRA_HTML_URL_ES5 = 'extra_html_url_es5' CONF_EXTRA_HTML_URL_ES5 = 'extra_html_url_es5'
CONF_FRONTEND_REPO = 'development_repo' CONF_FRONTEND_REPO = 'development_repo'
CONF_JS_VERSION = 'javascript_version' CONF_JS_VERSION = 'javascript_version'
JS_DEFAULT_OPTION = 'auto'
JS_OPTIONS = ['es5', 'latest', 'auto']
DEFAULT_THEME_COLOR = '#03A9F4' DEFAULT_THEME_COLOR = '#03A9F4'
MANIFEST_JSON = { MANIFEST_JSON = {
'background_color': '#FFFFFF', 'background_color': '#FFFFFF',
'description': 'Open-source home automation platform running on Python 3.', 'description':
'Home automation platform that puts local control and privacy first.',
'dir': 'ltr', 'dir': 'ltr',
'display': 'standalone', 'display': 'standalone',
'icons': [], 'icons': [],
@ -73,10 +71,9 @@ CONFIG_SCHEMA = vol.Schema({
}), }),
vol.Optional(CONF_EXTRA_HTML_URL): vol.Optional(CONF_EXTRA_HTML_URL):
vol.All(cv.ensure_list, [cv.string]), vol.All(cv.ensure_list, [cv.string]),
vol.Optional(CONF_EXTRA_HTML_URL_ES5): # We no longer use these options.
vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_EXTRA_HTML_URL_ES5): cv.match_all,
vol.Optional(CONF_JS_VERSION, default=JS_DEFAULT_OPTION): vol.Optional(CONF_JS_VERSION): cv.match_all,
vol.In(JS_OPTIONS)
}), }),
}, extra=vol.ALLOW_EXTRA) }, extra=vol.ALLOW_EXTRA)
@ -191,6 +188,15 @@ def add_manifest_json_key(key, val):
MANIFEST_JSON[key] = val MANIFEST_JSON[key] = val
def _frontend_root(dev_repo_path):
"""Return root path to the frontend files."""
if dev_repo_path is not None:
return pathlib.Path(dev_repo_path) / 'hass_frontend'
import hass_frontend
return hass_frontend.where()
async def async_setup(hass, config): async def async_setup(hass, config):
"""Set up the serving of the frontend.""" """Set up the serving of the frontend."""
await async_setup_frontend_storage(hass) await async_setup_frontend_storage(hass)
@ -207,39 +213,28 @@ async def async_setup(hass, config):
repo_path = conf.get(CONF_FRONTEND_REPO) repo_path = conf.get(CONF_FRONTEND_REPO)
is_dev = repo_path is not None is_dev = repo_path is not None
hass.data[DATA_JS_VERSION] = js_version = conf.get(CONF_JS_VERSION) root_path = _frontend_root(repo_path)
if is_dev: for path, should_cache in (
hass_frontend_path = os.path.join(repo_path, 'hass_frontend') ("service_worker.js", False),
hass_frontend_es5_path = os.path.join(repo_path, 'hass_frontend_es5') ("robots.txt", False),
else: ("onboarding.html", True),
import hass_frontend ("static", True),
import hass_frontend_es5 ("frontend_latest", True),
hass_frontend_path = hass_frontend.where() ("frontend_es5", True),
hass_frontend_es5_path = hass_frontend_es5.where() ):
hass.http.register_static_path(
"/{}".format(path), str(root_path / path), should_cache)
hass.http.register_static_path( hass.http.register_static_path(
"/service_worker_es5.js", "/auth/authorize", str(root_path / "authorize.html"), False)
os.path.join(hass_frontend_es5_path, "service_worker.js"), False)
hass.http.register_static_path(
"/service_worker.js",
os.path.join(hass_frontend_path, "service_worker.js"), False)
hass.http.register_static_path(
"/robots.txt",
os.path.join(hass_frontend_path, "robots.txt"), False)
hass.http.register_static_path("/static", hass_frontend_path, not is_dev)
hass.http.register_static_path(
"/frontend_latest", hass_frontend_path, not is_dev)
hass.http.register_static_path(
"/frontend_es5", hass_frontend_es5_path, not is_dev)
local = hass.config.path('www') local = hass.config.path('www')
if os.path.isdir(local): if os.path.isdir(local):
hass.http.register_static_path("/local", local, not is_dev) hass.http.register_static_path("/local", local, not is_dev)
index_view = IndexView(repo_path, js_version) index_view = IndexView(repo_path)
hass.http.register_view(index_view) hass.http.register_view(index_view)
hass.http.register_view(AuthorizeView(repo_path, js_version))
@callback @callback
def async_finalize_panel(panel): def async_finalize_panel(panel):
@ -263,13 +258,9 @@ async def async_setup(hass, config):
if DATA_EXTRA_HTML_URL not in hass.data: if DATA_EXTRA_HTML_URL not in hass.data:
hass.data[DATA_EXTRA_HTML_URL] = set() hass.data[DATA_EXTRA_HTML_URL] = set()
if DATA_EXTRA_HTML_URL_ES5 not in hass.data:
hass.data[DATA_EXTRA_HTML_URL_ES5] = set()
for url in conf.get(CONF_EXTRA_HTML_URL, []): for url in conf.get(CONF_EXTRA_HTML_URL, []):
add_extra_html_url(hass, url, False) add_extra_html_url(hass, url, False)
for url in conf.get(CONF_EXTRA_HTML_URL_ES5, []):
add_extra_html_url(hass, url, True)
_async_setup_themes(hass, conf.get(CONF_THEMES)) _async_setup_themes(hass, conf.get(CONF_THEMES))
@ -327,36 +318,6 @@ def _async_setup_themes(hass, themes):
hass.services.async_register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes) hass.services.async_register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes)
class AuthorizeView(HomeAssistantView):
"""Serve the frontend."""
url = '/auth/authorize'
name = 'auth:authorize'
requires_auth = False
def __init__(self, repo_path, js_option):
"""Initialize the frontend view."""
self.repo_path = repo_path
self.js_option = js_option
async def get(self, request: web.Request):
"""Redirect to the authorize page."""
latest = self.repo_path is not None or \
_is_latest(self.js_option, request)
if latest:
base = 'frontend_latest'
else:
base = 'frontend_es5'
location = "/{}/authorize.html{}".format(
base, str(request.url.relative())[15:])
return web.Response(status=302, headers={
'location': location
})
class IndexView(HomeAssistantView): class IndexView(HomeAssistantView):
"""Serve the frontend.""" """Serve the frontend."""
@ -364,70 +325,48 @@ class IndexView(HomeAssistantView):
name = 'frontend:index' name = 'frontend:index'
requires_auth = False requires_auth = False
def __init__(self, repo_path, js_option): def __init__(self, repo_path):
"""Initialize the frontend view.""" """Initialize the frontend view."""
self.repo_path = repo_path self.repo_path = repo_path
self.js_option = js_option self._template_cache = None
self._template_cache = {}
def get_template(self, latest): def get_template(self):
"""Get template.""" """Get template."""
if self.repo_path is not None: tpl = self._template_cache
root = os.path.join(self.repo_path, 'hass_frontend')
elif latest:
import hass_frontend
root = hass_frontend.where()
else:
import hass_frontend_es5
root = hass_frontend_es5.where()
tpl = self._template_cache.get(root)
if tpl is None: if tpl is None:
with open(os.path.join(root, 'index.html')) as file: with open(
str(_frontend_root(self.repo_path) / 'index.html')
) as file:
tpl = jinja2.Template(file.read()) tpl = jinja2.Template(file.read())
# Cache template if not running from repository # Cache template if not running from repository
if self.repo_path is None: if self.repo_path is None:
self._template_cache[root] = tpl self._template_cache = tpl
return tpl return tpl
async def get(self, request, extra=None): async def get(self, request, extra=None):
"""Serve the index view.""" """Serve the index view."""
hass = request.app['hass'] hass = request.app['hass']
latest = self.repo_path is not None or \
_is_latest(self.js_option, request)
if not hass.components.onboarding.async_is_onboarded(): if not hass.components.onboarding.async_is_onboarded():
if latest:
location = '/frontend_latest/onboarding.html'
else:
location = '/frontend_es5/onboarding.html'
return web.Response(status=302, headers={ return web.Response(status=302, headers={
'location': location 'location': '/onboarding.html'
}) })
no_auth = '1' template = self._template_cache
if not request[KEY_AUTHENTICATED]:
# do not try to auto connect on load
no_auth = '0'
template = await hass.async_add_job(self.get_template, latest) if template is None:
template = await hass.async_add_executor_job(self.get_template)
extra_key = DATA_EXTRA_HTML_URL if latest else DATA_EXTRA_HTML_URL_ES5 return web.Response(
text=template.render(
template_params = dict(
no_auth=no_auth,
theme_color=MANIFEST_JSON['theme_color'], theme_color=MANIFEST_JSON['theme_color'],
extra_urls=hass.data[extra_key], extra_urls=hass.data[DATA_EXTRA_HTML_URL],
use_oauth='1' ),
content_type='text/html'
) )
return web.Response(text=template.render(**template_params),
content_type='text/html')
class ManifestJSONView(HomeAssistantView): class ManifestJSONView(HomeAssistantView):
"""View to return a manifest.json.""" """View to return a manifest.json."""
@ -443,38 +382,6 @@ class ManifestJSONView(HomeAssistantView):
return web.Response(text=msg, content_type="application/manifest+json") return web.Response(text=msg, content_type="application/manifest+json")
def _is_latest(js_option, request):
"""
Return whether we should serve latest untranspiled code.
Set according to user's preference and URL override.
"""
import hass_frontend
if request is None:
return js_option == 'latest'
# latest in query
if 'latest' in request.query or (
request.headers.get('Referer') and
'latest' in urlparse(request.headers['Referer']).query):
return True
# es5 in query
if 'es5' in request.query or (
request.headers.get('Referer') and
'es5' in urlparse(request.headers['Referer']).query):
return False
# non-auto option in config
if js_option != 'auto':
return js_option == 'latest'
useragent = request.headers.get('User-Agent')
return useragent and hass_frontend.version(useragent)
@callback @callback
def websocket_get_panels(hass, connection, msg): def websocket_get_panels(hass, connection, msg):
"""Handle get panels command. """Handle get panels command.

View file

@ -3,7 +3,7 @@
"name": "Home Assistant Frontend", "name": "Home Assistant Frontend",
"documentation": "https://www.home-assistant.io/components/frontend", "documentation": "https://www.home-assistant.io/components/frontend",
"requirements": [ "requirements": [
"home-assistant-frontend==20190427.0" "home-assistant-frontend==20190502.0"
], ],
"dependencies": [ "dependencies": [
"api", "api",

View file

@ -560,7 +560,7 @@ hole==0.3.0
holidays==0.9.10 holidays==0.9.10
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20190427.0 home-assistant-frontend==20190502.0
# homeassistant.components.zwave # homeassistant.components.zwave
homeassistant-pyozw==0.1.4 homeassistant-pyozw==0.1.4

View file

@ -145,7 +145,7 @@ hdate==0.8.7
holidays==0.9.10 holidays==0.9.10
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20190427.0 home-assistant-frontend==20190502.0
# homeassistant.components.homekit_controller # homeassistant.components.homekit_controller
homekit[IP]==0.14.0 homekit[IP]==0.14.0

View file

@ -89,10 +89,6 @@ def test_frontend_and_static(mock_http_client, mock_onboarded):
@asyncio.coroutine @asyncio.coroutine
def test_dont_cache_service_worker(mock_http_client): def test_dont_cache_service_worker(mock_http_client):
"""Test that we don't cache the service worker.""" """Test that we don't cache the service worker."""
resp = yield from mock_http_client.get('/service_worker_es5.js')
assert resp.status == 200
assert 'cache-control' not in resp.headers
resp = yield from mock_http_client.get('/service_worker.js') resp = yield from mock_http_client.get('/service_worker.js')
assert resp.status == 200 assert resp.status == 200
assert 'cache-control' not in resp.headers assert 'cache-control' not in resp.headers
@ -233,16 +229,7 @@ def test_extra_urls(mock_http_client_with_urls, mock_onboarded):
resp = yield from mock_http_client_with_urls.get('/states?latest') resp = yield from mock_http_client_with_urls.get('/states?latest')
assert resp.status == 200 assert resp.status == 200
text = yield from resp.text() text = yield from resp.text()
assert text.find("href='https://domain.com/my_extra_url.html'") >= 0 assert text.find('href="https://domain.com/my_extra_url.html"') >= 0
@asyncio.coroutine
def test_extra_urls_es5(mock_http_client_with_urls, mock_onboarded):
"""Test that es5 extra urls are loaded."""
resp = yield from mock_http_client_with_urls.get('/states?es5')
assert resp.status == 200
text = yield from resp.text()
assert text.find("href='https://domain.com/my_extra_url_es5.html'") >= 0
async def test_get_panels(hass, hass_ws_client): async def test_get_panels(hass, hass_ws_client):
@ -330,15 +317,17 @@ async def test_auth_authorize(mock_http_client):
resp = await mock_http_client.get( resp = await mock_http_client.get(
'/auth/authorize?response_type=code&client_id=https://localhost/&' '/auth/authorize?response_type=code&client_id=https://localhost/&'
'redirect_uri=https://localhost/&state=123%23456') 'redirect_uri=https://localhost/&state=123%23456')
assert resp.status == 200
# No caching of auth page.
assert 'cache-control' not in resp.headers
assert str(resp.url.relative()) == ( text = await resp.text()
'/frontend_es5/authorize.html?response_type=code&client_id='
'https://localhost/&redirect_uri=https://localhost/&state=123%23456')
resp = await mock_http_client.get( # Test we can retrieve authorize.js
'/auth/authorize?latest&response_type=code&client_id=' authorizejs = re.search(
'https://localhost/&redirect_uri=https://localhost/&state=123%23456') r'(?P<app>\/frontend_latest\/authorize.[A-Za-z0-9]{8}.js)', text)
assert str(resp.url.relative()) == ( assert authorizejs is not None, text
'/frontend_latest/authorize.html?latest&response_type=code&client_id=' resp = await mock_http_client.get(authorizejs.groups(0)[0])
'https://localhost/&redirect_uri=https://localhost/&state=123%23456') assert resp.status == 200
assert 'public' in resp.headers.get('cache-control')