diff --git a/.eslintrc-hound.json b/.eslintrc-hound.json index 3361247052..ed4a480274 100644 --- a/.eslintrc-hound.json +++ b/.eslintrc-hound.json @@ -16,7 +16,7 @@ "__DEMO__": false, "__BUILD__": false, "__VERSION__": false, - "__ROOT__": false, + "__PUBLIC_PATH__": false, "Polymer": true, "webkitSpeechRecognition": false, "ResizeObserver": false @@ -52,6 +52,7 @@ "import/no-unresolved": 0, "import/extensions": [2, "ignorePackages"], "object-curly-newline": 0, + "default-case": 0, "react/jsx-no-bind": [2, { "ignoreRefs": true }], "react/jsx-no-duplicate-props": 2, "react/self-closing-comp": 2, diff --git a/gulp/tasks/gen-authorize-html.js b/gulp/tasks/gen-authorize-html.js index 3a25dde429..ad825fc658 100644 --- a/gulp/tasks/gen-authorize-html.js +++ b/gulp/tasks/gen-authorize-html.js @@ -12,7 +12,7 @@ const buildReplaces = { '/frontend_latest/authorize.js': 'authorize.js', }; -const es5Extra = ""; +const es5Extra = ""; async function buildAuth(es6) { const targetPath = es6 ? config.output : config.output_es5; diff --git a/gulp/tasks/gen-index-html.js b/gulp/tasks/gen-index-html.js index 756b76595a..5525c5fd36 100644 --- a/gulp/tasks/gen-index-html.js +++ b/gulp/tasks/gen-index-html.js @@ -30,7 +30,7 @@ function generateIndex(es6) { const compatibilityPath = `/frontend_es5/compatibility-${md5(path.resolve(config.output_es5, 'compatibility.js'))}.js`; const es5Extra = ` - + `; toReplace.push([ diff --git a/hassio/script/gen-index-html.js b/hassio/script/gen-index-html.js index 3fdb31651e..bb9323d4cf 100755 --- a/hassio/script/gen-index-html.js +++ b/hassio/script/gen-index-html.js @@ -7,7 +7,7 @@ let index = fs.readFileSync('index.html', 'utf-8'); const toReplace = [ [ '', - "" + "" ], ]; diff --git a/script/develop b/script/develop index c8cc0e1ccd..76afd860ad 100755 --- a/script/develop +++ b/script/develop @@ -18,8 +18,6 @@ cp -r public/__init__.py $OUTPUT_DIR_ES5/ cp src/authorize.html $OUTPUT_DIR # Manually copy over this file as we don't run the ES5 build -# The Hass.io panel depends on it. -cp node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js $OUTPUT_DIR_ES5 cp node_modules/@webcomponents/webcomponentsjs/webcomponents-bundle.js.map $OUTPUT_DIR ./node_modules/.bin/webpack --watch --progress diff --git a/src/common/dom/load_resource.js b/src/common/dom/load_resource.js new file mode 100644 index 0000000000..eefcfc627e --- /dev/null +++ b/src/common/dom/load_resource.js @@ -0,0 +1,35 @@ +// Load a resource and get a promise when loading done. +// From: https://davidwalsh.name/javascript-loader + +function _load(tag, url) { + // This promise will be used by Promise.all to determine success or failure + return new Promise(function (resolve, reject) { + const element = document.createElement(tag); + let attr = 'src'; + let parent = 'body'; + + // Important success and error for the promise + element.onload = () => resolve(url); + element.onerror = () => reject(url); + + // Need to set different attributes depending on tag type + switch (tag) { + case 'script': + element.async = true; + break; + case 'link': + element.type = 'text/css'; + element.rel = 'stylesheet'; + attr = 'href'; + parent = 'head'; + } + + // Inject into document to kick off loading + element[attr] = url; + document[parent].appendChild(element); + }); +} + +export const loadCSS = url => _load('link', url); +export const loadJS = url => _load('script', url); +export const loadImg = url => _load('img', url); diff --git a/src/entrypoints/custom-panel.js b/src/entrypoints/custom-panel.js new file mode 100644 index 0000000000..572124fb57 --- /dev/null +++ b/src/entrypoints/custom-panel.js @@ -0,0 +1,71 @@ +import { loadJS } from '../common/dom/load_resource.js'; +import loadCustomPanel from '../util/custom-panel/load-custom-panel.js'; +import createCustomPanelElement from '../util/custom-panel/create-custom-panel-element.js'; +import setCustomPanelProperties from '../util/custom-panel/set-custom-panel-properties.js'; + +const webComponentsSupported = ( + 'customElements' in window && + 'import' in document.createElement('link') && + 'content' in document.createElement('template')); + +let es5Loaded = null; + +window.loadES5Adapter = () => { + if (!es5Loaded) { + es5Loaded = loadJS(`${__PUBLIC_PATH__}custom-elements-es5-adapter.js`).catch(); + } + return es5Loaded; +}; + +let root = null; + +function setProperties(properties) { + if (root === null) return; + setCustomPanelProperties(root, properties); +} + +function initialize(panel, properties) { + const config = panel.config._panel_custom; + let start = Promise.resolve(); + + if (!webComponentsSupported) { + start = start.then(() => loadJS('/static/webcomponents-bundle.js')); + } + + if (__BUILD__ === 'es5') { + // Load ES5 adapter. Swallow errors as it raises errors on old browsers. + start = start.then(() => window.loadES5Adapter()); + } + + start + .then(() => loadCustomPanel(config)) + // If our element is using es5, let it finish loading that and define element + // This avoids elements getting upgraded after being added to the DOM + .then(() => (es5Loaded || Promise.resolve())) + .then( + () => { + root = createCustomPanelElement(config); + + const forwardEvent = ev => window.parent.customPanel.fire(ev.type, ev.detail); + root.addEventListener('hass-open-menu', forwardEvent); + root.addEventListener('hass-close-menu', forwardEvent); + root.addEventListener( + 'location-changed', + () => window.parent.customPanel.navigate(window.location.pathname) + ); + setProperties(Object.assign({ panel }, properties)); + document.body.appendChild(root); + }, + (err) => { + // eslint-disable-next-line + console.error(err, panel); + alert(`Unable to load the panel source: ${err}.`); + } + ); +} + +document.addEventListener( + 'DOMContentLoaded', + () => window.parent.customPanel.registerIframe(initialize, setProperties), + { once: true } +); diff --git a/src/layouts/partial-panel-resolver.js b/src/layouts/partial-panel-resolver.js index 78acd62c84..c3df760985 100644 --- a/src/layouts/partial-panel-resolver.js +++ b/src/layouts/partial-panel-resolver.js @@ -21,6 +21,10 @@ function ensureLoaded(panel) { imported = import(/* webpackChunkName: "panel-config" */ '../panels/config/ha-panel-config.js'); break; + case 'custom': + imported = import(/* webpackChunkName: "panel-custom" */ '../panels/custom/ha-panel-custom.js'); + break; + case 'dev-event': imported = import(/* webpackChunkName: "panel-dev-event" */ '../panels/dev-event/ha-panel-dev-event.js'); break; diff --git a/src/panels/custom/ha-panel-custom.js b/src/panels/custom/ha-panel-custom.js new file mode 100644 index 0000000000..3a21809918 --- /dev/null +++ b/src/panels/custom/ha-panel-custom.js @@ -0,0 +1,123 @@ +import { PolymerElement } from '@polymer/polymer/polymer-element.js'; + +import EventsMixin from '../../mixins/events-mixin.js'; +import NavigateMixin from '../../mixins/navigate-mixin.js'; +import loadCustomPanel from '../../util/custom-panel/load-custom-panel.js'; +import createCustomPanelElement from '../../util/custom-panel/create-custom-panel-element.js'; +import setCustomPanelProperties from '../../util/custom-panel/set-custom-panel-properties.js'; + +/* + * Mixins are used by ifram to communicate with main frontend. + * @appliesMixin EventsMixin + * @appliesMixin NavigateMixin + */ +class HaPanelCustom extends NavigateMixin(EventsMixin(PolymerElement)) { + static get properties() { + return { + hass: Object, + narrow: Boolean, + showMenu: Boolean, + route: Object, + panel: { + type: Object, + observer: '_panelChanged', + } + }; + } + + static get observers() { + return [ + '_dataChanged(hass, narrow, showMenu, route)' + ]; + } + + constructor() { + super(); + this._setProperties = null; + } + + _panelChanged(panel) { + // Clean up + delete window.customPanel; + this._setProperties = null; + while (this.lastChild) { + this.remove(this.lastChild); + } + + const config = panel.config._panel_custom; + + const tempA = document.createElement('a'); + tempA.href = config.html_url || config.js_url; + + if (!config.trust_external && !['localhost', '127.0.0.1', location.hostname].includes(tempA.hostname)) { + if (!confirm(`Do you trust the external panel "${config.name}" at "${tempA.href}"? + +It will have access to all data in Home Assistant. + +(Check docs for the panel_custom component to hide this message)`)) { + return; + } + } + + if (!config.embed_iframe) { + loadCustomPanel(config) + .then( + () => { + const element = createCustomPanelElement(config); + this._setProperties = props => setCustomPanelProperties(element, props); + setCustomPanelProperties(element, { + panel, + hass: this.hass, + narrow: this.narrow, + showMenu: this.showMenu, + route: this.route, + }); + this.appendChild(element); + }, + () => { + alert(`Unable to load custom panel from ${tempA.href}`); + } + ); + return; + } + + window.customPanel = this; + this.innerHTML = ` + + + `; + const iframeDoc = this.querySelector('iframe').contentWindow.document; + iframeDoc.open(); + iframeDoc.write(``); + iframeDoc.close(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + delete window.customPanel; + } + + _dataChanged(hass, narrow, showMenu, route) { + if (!this._setProperties) return; + this._setProperties({ hass, narrow, showMenu, route }); + } + + registerIframe(initialize, setProperties) { + initialize(this.panel, { + hass: this.hass, + narrow: this.narrow, + showMenu: this.showMenu, + route: this.route, + }); + this._setProperties = setProperties; + } +} + +customElements.define('ha-panel-custom', HaPanelCustom); diff --git a/src/resources/html-import/import-href.js b/src/resources/html-import/import-href.js index 602f9dfbba..92ad3f8c50 100644 --- a/src/resources/html-import/import-href.js +++ b/src/resources/html-import/import-href.js @@ -1,4 +1,5 @@ /* eslint-disable */ +import './polyfill.js'; /** @license Copyright (c) 2017 The Polymer Project Authors. All rights reserved. @@ -94,3 +95,6 @@ export const importHref = function (href, onload, onerror, optAsync) { } return link; }; + +export const importHrefPromise = href => + new Promise((resolve, reject) => importHref(href, resolve, reject)); diff --git a/src/util/custom-panel/create-custom-panel-element.js b/src/util/custom-panel/create-custom-panel-element.js new file mode 100644 index 0000000000..99dcc7de11 --- /dev/null +++ b/src/util/custom-panel/create-custom-panel-element.js @@ -0,0 +1,5 @@ +export default function createCustomPanelElement(panelConfig) { + // Legacy support. Custom panels used to have to define element ha-panel-{name} + const tagName = 'html_url' in panelConfig ? `ha-panel-${panelConfig.name}` : panelConfig.name; + return document.createElement(tagName); +} diff --git a/src/util/custom-panel/load-custom-panel.js b/src/util/custom-panel/load-custom-panel.js new file mode 100644 index 0000000000..2a86fd0450 --- /dev/null +++ b/src/util/custom-panel/load-custom-panel.js @@ -0,0 +1,20 @@ +import { loadJS } from '../../common/dom/load_resource.js'; + +// Make sure we only import every JS-based panel once (HTML import has this built-in) +const JS_CACHE = {}; + +export default function loadCustomPanel(panelConfig) { + if ('html_url' in panelConfig) { + return Promise.all([ + import('../legacy-support.js'), + import('../../resources/html-import/import-href.js'), + // eslint-disable-next-line + ]).then(([{}, { importHrefPromise }]) => importHrefPromise(panelConfig.html_url)); + } else if (panelConfig.js_url) { + if (!(panelConfig.js_url in JS_CACHE)) { + JS_CACHE[panelConfig.js_url] = loadJS(panelConfig.js_url); + } + return JS_CACHE[panelConfig.js_url]; + } + return Promise.reject('No valid url found in panel config.'); +} diff --git a/src/util/custom-panel/set-custom-panel-properties.js b/src/util/custom-panel/set-custom-panel-properties.js new file mode 100644 index 0000000000..f4c774bfce --- /dev/null +++ b/src/util/custom-panel/set-custom-panel-properties.js @@ -0,0 +1,9 @@ +export default function setCustomPanelProperties(root, properties) { + if ('setProperties' in root) { + root.setProperties(properties); + } else { + Object.keys(properties).forEach((key) => { + root[key] = properties[key]; + }); + } +} diff --git a/webpack.config.js b/webpack.config.js index 5816e51240..7c7e42c44e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -18,6 +18,7 @@ function createConfig(isProdBuild, latestBuild) { app: './src/entrypoints/app.js', authorize: './src/entrypoints/authorize.js', core: './src/entrypoints/core.js', + 'custom-panel': './src/entrypoints/custom-panel.js', }; const babelOptions = { @@ -42,6 +43,7 @@ function createConfig(isProdBuild, latestBuild) { __DEV__: JSON.stringify(!isProdBuild), __BUILD__: JSON.stringify(latestBuild ? 'latest' : 'es5'), __VERSION__: JSON.stringify(VERSION), + __PUBLIC_PATH__: JSON.stringify(publicPath), }), new CopyWebpackPlugin(copyPluginOpts), // Ignore moment.js locales @@ -64,9 +66,9 @@ function createConfig(isProdBuild, latestBuild) { copyPluginOpts.push('node_modules/@webcomponents/webcomponentsjs/webcomponents-bundle.js') copyPluginOpts.push({ from: 'node_modules/leaflet/dist/leaflet.css', to: `images/leaflet/` }); copyPluginOpts.push({ from: 'node_modules/leaflet/dist/images', to: `images/leaflet/` }); + copyPluginOpts.push('node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js'); entry['hass-icons'] = './src/entrypoints/hass-icons.js'; } else { - copyPluginOpts.push('node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js'); babelOptions.presets = [ ['es2015', { modules: false }] ];