diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index ac2fe252b47..776580a7d39 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -1,30 +1,13 @@ """Handle the frontend for Home Assistant.""" -import re import os -import logging from . import version, mdi_version -from homeassistant.const import URL_ROOT from homeassistant.components import api from homeassistant.components.http import HomeAssistantView DOMAIN = 'frontend' DEPENDENCIES = ['api'] -INDEX_PATH = os.path.join(os.path.dirname(__file__), 'index.html.template') - -_LOGGER = logging.getLogger(__name__) - -FRONTEND_URLS = [ - URL_ROOT, '/logbook', '/history', '/map', '/devService', '/devState', - '/devEvent', '/devInfo', '/devTemplate', - re.compile(r'/states(/([a-zA-Z\._\-0-9/]+)|)'), -] - -URL_API_BOOTSTRAP = "/api/bootstrap" - -_FINGERPRINT = re.compile(r'^(\w+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE) - def setup(hass, config): """Setup serving the frontend.""" @@ -39,7 +22,8 @@ def setup(hass, config): hass.wsgi.register_static_path( "/service_worker.js", - os.path.join(www_static_path, sw_path) + os.path.join(www_static_path, sw_path), + 0 ) hass.wsgi.register_static_path("/static", www_static_path) hass.wsgi.register_static_path("/local", hass.config.path('www')) @@ -50,7 +34,7 @@ def setup(hass, config): class BootstrapView(HomeAssistantView): """View to bootstrap frontend with all needed data.""" - url = URL_API_BOOTSTRAP + url = "/api/bootstrap" name = "api:bootstrap" def get(self, request): @@ -66,7 +50,7 @@ class BootstrapView(HomeAssistantView): class IndexView(HomeAssistantView): """Serve the frontend.""" - url = URL_ROOT + url = '/' name = "frontend:index" requires_auth = False extra_urls = ['/logbook', '/history', '/map', '/devService', '/devState', @@ -92,11 +76,11 @@ class IndexView(HomeAssistantView): else: app_url = "frontend-{}.html".format(version.VERSION) - # auto login if no password was set, else check api_password param + # auto login if no password was set if self.hass.config.api.api_password is None: auth = 'no_password_set' else: - auth = request.values.get('api_password', '') + auth = '' template = self.templates.get_template('index.html') diff --git a/homeassistant/components/frontend/index.html.template b/homeassistant/components/frontend/index.html.template deleted file mode 100644 index cedebe1bbb4..00000000000 --- a/homeassistant/components/frontend/index.html.template +++ /dev/null @@ -1,86 +0,0 @@ - - - - - Home Assistant - - - - - - - - - - - - - -
- - - Home Assistant had trouble
connecting to the server.

TRY AGAIN -
- - - - diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html index e21d00e86bc..cedebe1bbb4 100644 --- a/homeassistant/components/frontend/templates/index.html +++ b/homeassistant/components/frontend/templates/index.html @@ -28,20 +28,55 @@ left: 0; right: 0; bottom: 0; - margin-bottom: 123px; + margin-bottom: 97px; + font-family: Roboto, sans-serif; + font-size: 0pt; + transition: font-size 2s; + } + + #ha-init-skeleton paper-spinner { + height: 28px; + } + + #ha-init-skeleton a { + color: #03A9F4; + text-decoration: none; + font-weight: bold; + } + + #ha-init-skeleton.error { + font-size: 16px; + } + + #ha-init-skeleton.error img, + #ha-init-skeleton.error paper-spinner { + display: none; } - + + -
+
+ + + Home Assistant had trouble
connecting to the server.

TRY AGAIN +
-- \ No newline at end of file + clear: both;white-space:pre-wrap} \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/frontend.html.gz b/homeassistant/components/frontend/www_static/frontend.html.gz index 115bfc3e4a8..f4771004263 100644 Binary files a/homeassistant/components/frontend/www_static/frontend.html.gz and b/homeassistant/components/frontend/www_static/frontend.html.gz differ diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index 6d2dd25a4ca..0be98873d70 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 6d2dd25a4cab6ff105b42497a3c1684ee7ab138b +Subproject commit 0be98873d7044f387645f3a694e41660be663b66 diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index 4346db8b9a0..ef453ef3dd1 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -1 +1,258 @@ -!function(e){function t(r){if(n[r])return n[r].exports;var s=n[r]={i:r,l:!1,exports:{}};return e[r].call(s.exports,s,s.exports,t),s.l=!0,s.exports}var n={};return t.m=e,t.c=n,t.p="",t(t.s=194)}({194:function(e,t,n){var r="0.10",s="/",c=["/","/logbook","/history","/map","/devService","/devState","/devEvent","/devInfo","/states"],i=["/static/favicon-192x192.png"];self.addEventListener("install",function(e){e.waitUntil(caches.open(r).then(function(e){return e.addAll(i.concat(s))}))}),self.addEventListener("activate",function(e){}),self.addEventListener("message",function(e){}),self.addEventListener("fetch",function(e){var t=e.request.url.substr(e.request.url.indexOf("/",8));i.includes(t)&&e.respondWith(caches.open(r).then(function(t){return t.match(e.request)})),c.includes(t)&&e.respondWith(caches.open(r).then(function(t){return t.match(s).then(function(n){return n||fetch(e.request).then(function(e){return t.put(s,e.clone()),e})})}))})}}); \ No newline at end of file +/** + * Copyright 2016 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This generated service worker JavaScript will precache your site's resources. +// The code needs to be saved in a .js file at the top-level of your site, and registered +// from your pages in order to be used. See +// https://github.com/googlechrome/sw-precache/blob/master/demo/app/js/service-worker-registration.js +// for an example of how you can register this script and handle various service worker events. + +/* eslint-env worker, serviceworker */ +/* eslint-disable indent, no-unused-vars, no-multiple-empty-lines, max-nested-callbacks, space-before-function-paren */ +'use strict'; + + + + + +/* eslint-disable quotes, comma-spacing */ +var PrecacheConfig = [["/","2e74fd3303cf5bbeb72a2f1b4d18fccd"],["/devEvent","2e74fd3303cf5bbeb72a2f1b4d18fccd"],["/devInfo","2e74fd3303cf5bbeb72a2f1b4d18fccd"],["/devService","2e74fd3303cf5bbeb72a2f1b4d18fccd"],["/devState","2e74fd3303cf5bbeb72a2f1b4d18fccd"],["/devTemplate","2e74fd3303cf5bbeb72a2f1b4d18fccd"],["/history","2e74fd3303cf5bbeb72a2f1b4d18fccd"],["/logbook","2e74fd3303cf5bbeb72a2f1b4d18fccd"],["/map","2e74fd3303cf5bbeb72a2f1b4d18fccd"],["/states","2e74fd3303cf5bbeb72a2f1b4d18fccd"],["/static/frontend-61a4974868291c31d0b189d962750e76.html","2e74fd3303cf5bbeb72a2f1b4d18fccd"],["/static/mdi-9ee3d4466a65bef35c2c8974e91b37c0.html","9a6846935116cd29279c91e0ee0a26d0"],["static/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"],["static/webcomponents-lite.min.js","b0f32ad3c7749c40d486603f31c9d8b1"]]; +/* eslint-enable quotes, comma-spacing */ +var CacheNamePrefix = 'sw-precache-v1--' + (self.registration ? self.registration.scope : '') + '-'; + + +var IgnoreUrlParametersMatching = [/^utm_/]; + + + +var addDirectoryIndex = function (originalUrl, index) { + var url = new URL(originalUrl); + if (url.pathname.slice(-1) === '/') { + url.pathname += index; + } + return url.toString(); + }; + +var getCacheBustedUrl = function (url, param) { + param = param || Date.now(); + + var urlWithCacheBusting = new URL(url); + urlWithCacheBusting.search += (urlWithCacheBusting.search ? '&' : '') + + 'sw-precache=' + param; + + return urlWithCacheBusting.toString(); + }; + +var isPathWhitelisted = function (whitelist, absoluteUrlString) { + // If the whitelist is empty, then consider all URLs to be whitelisted. + if (whitelist.length === 0) { + return true; + } + + // Otherwise compare each path regex to the path of the URL passed in. + var path = (new URL(absoluteUrlString)).pathname; + return whitelist.some(function(whitelistedPathRegex) { + return path.match(whitelistedPathRegex); + }); + }; + +var populateCurrentCacheNames = function (precacheConfig, + cacheNamePrefix, baseUrl) { + var absoluteUrlToCacheName = {}; + var currentCacheNamesToAbsoluteUrl = {}; + + precacheConfig.forEach(function(cacheOption) { + var absoluteUrl = new URL(cacheOption[0], baseUrl).toString(); + var cacheName = cacheNamePrefix + absoluteUrl + '-' + cacheOption[1]; + currentCacheNamesToAbsoluteUrl[cacheName] = absoluteUrl; + absoluteUrlToCacheName[absoluteUrl] = cacheName; + }); + + return { + absoluteUrlToCacheName: absoluteUrlToCacheName, + currentCacheNamesToAbsoluteUrl: currentCacheNamesToAbsoluteUrl + }; + }; + +var stripIgnoredUrlParameters = function (originalUrl, + ignoreUrlParametersMatching) { + var url = new URL(originalUrl); + + url.search = url.search.slice(1) // Exclude initial '?' + .split('&') // Split into an array of 'key=value' strings + .map(function(kv) { + return kv.split('='); // Split each 'key=value' string into a [key, value] array + }) + .filter(function(kv) { + return ignoreUrlParametersMatching.every(function(ignoredRegex) { + return !ignoredRegex.test(kv[0]); // Return true iff the key doesn't match any of the regexes. + }); + }) + .map(function(kv) { + return kv.join('='); // Join each [key, value] array into a 'key=value' string + }) + .join('&'); // Join the array of 'key=value' strings into a string with '&' in between each + + return url.toString(); + }; + + +var mappings = populateCurrentCacheNames(PrecacheConfig, CacheNamePrefix, self.location); +var AbsoluteUrlToCacheName = mappings.absoluteUrlToCacheName; +var CurrentCacheNamesToAbsoluteUrl = mappings.currentCacheNamesToAbsoluteUrl; + +function deleteAllCaches() { + return caches.keys().then(function(cacheNames) { + return Promise.all( + cacheNames.map(function(cacheName) { + return caches.delete(cacheName); + }) + ); + }); +} + +self.addEventListener('install', function(event) { + event.waitUntil( + // Take a look at each of the cache names we expect for this version. + Promise.all(Object.keys(CurrentCacheNamesToAbsoluteUrl).map(function(cacheName) { + return caches.open(cacheName).then(function(cache) { + // Get a list of all the entries in the specific named cache. + // For caches that are already populated for a given version of a + // resource, there should be 1 entry. + return cache.keys().then(function(keys) { + // If there are 0 entries, either because this is a brand new version + // of a resource or because the install step was interrupted the + // last time it ran, then we need to populate the cache. + if (keys.length === 0) { + // Use the last bit of the cache name, which contains the hash, + // as the cache-busting parameter. + // See https://github.com/GoogleChrome/sw-precache/issues/100 + var cacheBustParam = cacheName.split('-').pop(); + var urlWithCacheBusting = getCacheBustedUrl( + CurrentCacheNamesToAbsoluteUrl[cacheName], cacheBustParam); + + var request = new Request(urlWithCacheBusting, + {credentials: 'same-origin'}); + return fetch(request).then(function(response) { + if (response.ok) { + return cache.put(CurrentCacheNamesToAbsoluteUrl[cacheName], + response); + } + + console.error('Request for %s returned a response status %d, ' + + 'so not attempting to cache it.', + urlWithCacheBusting, response.status); + // Get rid of the empty cache if we can't add a successful response to it. + return caches.delete(cacheName); + }); + } + }); + }); + })).then(function() { + return caches.keys().then(function(allCacheNames) { + return Promise.all(allCacheNames.filter(function(cacheName) { + return cacheName.indexOf(CacheNamePrefix) === 0 && + !(cacheName in CurrentCacheNamesToAbsoluteUrl); + }).map(function(cacheName) { + return caches.delete(cacheName); + }) + ); + }); + }).then(function() { + if (typeof self.skipWaiting === 'function') { + // Force the SW to transition from installing -> active state + self.skipWaiting(); + } + }) + ); +}); + +if (self.clients && (typeof self.clients.claim === 'function')) { + self.addEventListener('activate', function(event) { + event.waitUntil(self.clients.claim()); + }); +} + +self.addEventListener('message', function(event) { + if (event.data.command === 'delete_all') { + console.log('About to delete all caches...'); + deleteAllCaches().then(function() { + console.log('Caches deleted.'); + event.ports[0].postMessage({ + error: null + }); + }).catch(function(error) { + console.log('Caches not deleted:', error); + event.ports[0].postMessage({ + error: error + }); + }); + } +}); + + +self.addEventListener('fetch', function(event) { + if (event.request.method === 'GET') { + var urlWithoutIgnoredParameters = stripIgnoredUrlParameters(event.request.url, + IgnoreUrlParametersMatching); + + var cacheName = AbsoluteUrlToCacheName[urlWithoutIgnoredParameters]; + var directoryIndex = 'index.html'; + if (!cacheName && directoryIndex) { + urlWithoutIgnoredParameters = addDirectoryIndex(urlWithoutIgnoredParameters, directoryIndex); + cacheName = AbsoluteUrlToCacheName[urlWithoutIgnoredParameters]; + } + + var navigateFallback = ''; + // Ideally, this would check for event.request.mode === 'navigate', but that is not widely + // supported yet: + // https://code.google.com/p/chromium/issues/detail?id=540967 + // https://bugzilla.mozilla.org/show_bug.cgi?id=1209081 + if (!cacheName && navigateFallback && event.request.headers.has('accept') && + event.request.headers.get('accept').includes('text/html') && + /* eslint-disable quotes, comma-spacing */ + isPathWhitelisted([], event.request.url)) { + /* eslint-enable quotes, comma-spacing */ + var navigateFallbackUrl = new URL(navigateFallback, self.location); + cacheName = AbsoluteUrlToCacheName[navigateFallbackUrl.toString()]; + } + + if (cacheName) { + event.respondWith( + // Rely on the fact that each cache we manage should only have one entry, and return that. + caches.open(cacheName).then(function(cache) { + return cache.keys().then(function(keys) { + return cache.match(keys[0]).then(function(response) { + if (response) { + return response; + } + // If for some reason the response was deleted from the cache, + // raise and exception and fall back to the fetch() triggered in the catch(). + throw Error('The cache ' + cacheName + ' is empty.'); + }); + }); + }).catch(function(e) { + console.warn('Couldn\'t serve response for "%s" from cache: %O', event.request.url, e); + return fetch(event.request); + }) + ); + } + } +}); + + + + diff --git a/homeassistant/components/frontend/www_static/service_worker.js.gz b/homeassistant/components/frontend/www_static/service_worker.js.gz new file mode 100644 index 00000000000..fb4ab0fe0d4 Binary files /dev/null and b/homeassistant/components/frontend/www_static/service_worker.js.gz differ diff --git a/homeassistant/components/http.py b/homeassistant/components/http.py index 6b2dc53a59f..b431cef22bf 100644 --- a/homeassistant/components/http.py +++ b/homeassistant/components/http.py @@ -232,15 +232,18 @@ class HomeAssistantWSGI(object): self.url_map.add(Rule(url, redirect_to=redirect_to)) - def register_static_path(self, url_root, path): - """Register a folder to serve as a static path.""" + def register_static_path(self, url_root, path, cache_length=31): + """Register a folder to serve as a static path. + + Specify optional cache length of asset in days. + """ from static import Cling headers = [] - if not self.development: + if cache_length and not self.development: # 1 year in seconds - cache_time = 365 * 86400 + cache_time = cache_length * 86400 headers.append({ 'prefix': '', diff --git a/script/build_frontend b/script/build_frontend index b5e41da21df..1af506f6722 100755 --- a/script/build_frontend +++ b/script/build_frontend @@ -7,9 +7,12 @@ npm run frontend_prod cp bower_components/webcomponentsjs/webcomponents-lite.min.js .. cp build/frontend.html .. -cp build/service_worker.js .. gzip build/frontend.html -c -k -9 > ../frontend.html.gz +node script/sw-precache.js +cp build/service_worker.js .. +gzip build/service_worker.js -c -k -9 > ../service_worker.js.gz + # Generate the MD5 hash of the new frontend cd ../.. echo '"""DO NOT MODIFY. Auto-generated by build_frontend script."""' > version.py diff --git a/tests/components/test_frontend.py b/tests/components/test_frontend.py index 54ca023c88e..61e33931c24 100644 --- a/tests/components/test_frontend.py +++ b/tests/components/test_frontend.py @@ -77,17 +77,6 @@ class TestFrontend(unittest.TestCase): self.assertEqual(200, req.status_code) - def test_auto_filling_in_api_password(self): - """Test for auto filling of API password.""" - req = requests.get( - _url("?{}={}".format(http.DATA_API_PASSWORD, API_PASSWORD))) - - self.assertEqual(200, req.status_code) - - auth_text = re.search(r"auth='{}'".format(API_PASSWORD), req.text) - - self.assertIsNotNone(auth_text) - def test_404(self): """Test for HTTP 404 error.""" self.assertEqual(404, requests.get(_url("/not-existing")).status_code)