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 }]
];