diff --git a/src/components/ha-sidebar.ts b/src/components/ha-sidebar.ts
index 50428786ad..480e2a27eb 100644
--- a/src/components/ha-sidebar.ts
+++ b/src/components/ha-sidebar.ts
@@ -18,6 +18,10 @@ import isComponentLoaded from "../common/config/is_component_loaded";
import { HomeAssistant, PanelInfo } from "../types";
import { fireEvent } from "../common/dom/fire_event";
import { DEFAULT_PANEL } from "../common/const";
+import {
+ getExternalConfig,
+ ExternalConfig,
+} from "../external_app/external_config";
const computeUrl = (urlPath) => `/${urlPath}`;
@@ -69,6 +73,7 @@ class HaSidebar extends LitElement {
@property() public hass?: HomeAssistant;
@property() public _defaultPage?: string =
localStorage.defaultPage || DEFAULT_PANEL;
+ @property() private _externalConfig?: ExternalConfig;
protected render() {
const hass = this.hass;
@@ -117,6 +122,27 @@ class HaSidebar extends LitElement {
`
)}
+ ${this._externalConfig && this._externalConfig.hasSettingsScreen
+ ? html`
+
+
+
+ ${hass.localize(
+ "ui.sidebar.external_app_configuration"
+ )}
+
+
+ `
+ : ""}
${!hass.user
? html`
@@ -193,6 +219,9 @@ class HaSidebar extends LitElement {
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
+ if (changedProps.has("_externalConfig")) {
+ return true;
+ }
if (!this.hass || !changedProps.has("hass")) {
return false;
}
@@ -210,10 +239,26 @@ class HaSidebar extends LitElement {
);
}
+ protected firstUpdated(changedProps: PropertyValues) {
+ super.firstUpdated(changedProps);
+ if (this.hass && this.hass.auth.external) {
+ getExternalConfig(this.hass.auth.external).then((conf) => {
+ this._externalConfig = conf;
+ });
+ }
+ }
+
private _handleLogOut() {
fireEvent(this, "hass-logout");
}
+ private _handleExternalAppConfiguration(ev: Event) {
+ ev.preventDefault();
+ this.hass!.auth.external!.fireMessage({
+ type: "config_screen/show",
+ });
+ }
+
static get styles(): CSSResult {
return css`
:host {
@@ -259,7 +304,7 @@ class HaSidebar extends LitElement {
--paper-item-min-height: 40px;
}
- a ha-icon {
+ ha-icon[slot="item-icon"] {
color: var(--sidebar-icon-color);
}
diff --git a/src/entrypoints/core.ts b/src/entrypoints/core.ts
index b4deef57d1..92a535d078 100644
--- a/src/entrypoints/core.ts
+++ b/src/entrypoints/core.ts
@@ -26,7 +26,7 @@ const isExternal = location.search.includes("external_auth=1");
const authProm = isExternal
? () =>
- import(/* webpackChunkName: "external_auth" */ "../common/auth/external_auth").then(
+ import(/* webpackChunkName: "external_auth" */ "../external_app/external_auth").then(
(mod) => new mod.default(hassUrl)
)
: () =>
diff --git a/src/common/auth/external_auth.ts b/src/external_app/external_auth.ts
similarity index 85%
rename from src/common/auth/external_auth.ts
rename to src/external_app/external_auth.ts
index cbb4e573aa..e463f26e68 100644
--- a/src/common/auth/external_auth.ts
+++ b/src/external_app/external_auth.ts
@@ -2,6 +2,7 @@
* Auth class that connects to a native app for authentication.
*/
import { Auth } from "home-assistant-js-websocket";
+import { ExternalMessaging, InternalMessage } from "./external_messaging";
const CALLBACK_SET_TOKEN = "externalAuthSetToken";
const CALLBACK_REVOKE_TOKEN = "externalAuthRevokeToken";
@@ -20,6 +21,7 @@ declare global {
externalApp?: {
getExternalAuth(payload: string);
revokeExternalAuth(payload: string);
+ externalBus(payload: string);
};
webkit?: {
messageHandlers: {
@@ -29,6 +31,9 @@ declare global {
revokeExternalAuth: {
postMessage(payload: BasePayload);
};
+ externalBus: {
+ postMessage(payload: InternalMessage);
+ };
};
};
}
@@ -41,6 +46,8 @@ if (!window.externalApp && !window.webkit) {
}
export default class ExternalAuth extends Auth {
+ public external = new ExternalMessaging();
+
constructor(hassUrl) {
super({
hassUrl,
@@ -51,19 +58,10 @@ export default class ExternalAuth extends Auth {
// This will trigger connection to do a refresh right away
expires: 0,
});
+ this.external.attach();
}
public async refreshAccessToken() {
- const responseProm = new Promise(
- (resolve, reject) => {
- window[CALLBACK_SET_TOKEN] = (success, data) =>
- success ? resolve(data) : reject(data);
- }
- );
-
- // Allow promise to set resolve on window object.
- await 0;
-
const callbackPayload = { callback: CALLBACK_SET_TOKEN };
if (window.externalApp) {
@@ -74,21 +72,18 @@ export default class ExternalAuth extends Auth {
);
}
- const tokens = await responseProm;
+ const tokens = await new Promise(
+ (resolve, reject) => {
+ window[CALLBACK_SET_TOKEN] = (success, data) =>
+ success ? resolve(data) : reject(data);
+ }
+ );
this.data.access_token = tokens.access_token;
this.data.expires = tokens.expires_in * 1000 + Date.now();
}
public async revoke() {
- const responseProm = new Promise((resolve, reject) => {
- window[CALLBACK_REVOKE_TOKEN] = (success, data) =>
- success ? resolve(data) : reject(data);
- });
-
- // Allow promise to set resolve on window object.
- await 0;
-
const callbackPayload = { callback: CALLBACK_REVOKE_TOKEN };
if (window.externalApp) {
@@ -99,6 +94,9 @@ export default class ExternalAuth extends Auth {
);
}
- await responseProm;
+ await new Promise((resolve, reject) => {
+ window[CALLBACK_REVOKE_TOKEN] = (success, data) =>
+ success ? resolve(data) : reject(data);
+ });
}
}
diff --git a/src/external_app/external_config.ts b/src/external_app/external_config.ts
new file mode 100644
index 0000000000..7651b1307b
--- /dev/null
+++ b/src/external_app/external_config.ts
@@ -0,0 +1,16 @@
+import { ExternalMessaging } from "./external_messaging";
+
+export interface ExternalConfig {
+ hasSettingsScreen: boolean;
+}
+
+export const getExternalConfig = (
+ bus: ExternalMessaging
+): Promise => {
+ if (!bus.cache.cfg) {
+ bus.cache.cfg = bus.sendMessage({
+ type: "config/get",
+ });
+ }
+ return bus.cache.cfg;
+};
diff --git a/src/external_app/external_messaging.ts b/src/external_app/external_messaging.ts
new file mode 100644
index 0000000000..173fb62606
--- /dev/null
+++ b/src/external_app/external_messaging.ts
@@ -0,0 +1,103 @@
+const CALLBACK_EXTERNAL_BUS = "externalBus";
+
+interface CommandInFlight {
+ resolve: (data: any) => void;
+ reject: (err: ExternalError) => void;
+}
+
+export interface InternalMessage {
+ id?: number;
+ type: string;
+}
+
+interface ExternalError {
+ code: string;
+ message: string;
+}
+
+interface ExternalMessageResult {
+ id: number;
+ type: "result";
+ success: true;
+ result: unknown;
+}
+
+interface ExternalMessageResultError {
+ id: number;
+ type: "result";
+ success: false;
+ error: ExternalError;
+}
+
+type ExternalMessage = ExternalMessageResult | ExternalMessageResultError;
+
+export class ExternalMessaging {
+ public commands: { [msgId: number]: CommandInFlight } = {};
+ public cache: { [key: string]: any } = {};
+ public msgId = 0;
+
+ public attach() {
+ window[CALLBACK_EXTERNAL_BUS] = (msg) => this.receiveMessage(msg);
+ }
+
+ /**
+ * Send message to external app that expects a response.
+ * @param msg message to send
+ */
+ public sendMessage(msg: InternalMessage): Promise {
+ const msgId = ++this.msgId;
+ msg.id = msgId;
+
+ this.fireMessage(msg);
+
+ return new Promise((resolve, reject) => {
+ this.commands[msgId] = { resolve, reject };
+ });
+ }
+
+ /**
+ * Send message to external app without expecting a response.
+ * @param msg message to send
+ */
+ public fireMessage(msg: InternalMessage) {
+ if (!msg.id) {
+ msg.id = ++this.msgId;
+ }
+ this._sendExternal(msg);
+ }
+
+ public receiveMessage(msg: ExternalMessage) {
+ if (__DEV__) {
+ // tslint:disable-next-line: no-console
+ console.log("Receiving message from external app", msg);
+ }
+
+ const pendingCmd = this.commands[msg.id];
+
+ if (!pendingCmd) {
+ // tslint:disable-next-line: no-console
+ console.warn(`Received unknown msg ID`, msg.id);
+ return;
+ }
+
+ if (msg.type === "result") {
+ if (msg.success) {
+ pendingCmd.resolve(msg.result);
+ } else {
+ pendingCmd.reject(msg.error);
+ }
+ }
+ }
+
+ protected _sendExternal(msg: InternalMessage) {
+ if (__DEV__) {
+ // tslint:disable-next-line: no-console
+ console.log("Sending message to external app", msg);
+ }
+ if (window.externalApp) {
+ window.externalApp.externalBus(JSON.stringify(msg));
+ } else {
+ window.webkit!.messageHandlers.externalBus.postMessage(msg);
+ }
+ }
+}
diff --git a/src/types.ts b/src/types.ts
index d5ffd71605..4cad83fdf9 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -9,6 +9,7 @@ import {
HassServices,
} from "home-assistant-js-websocket";
import { LocalizeFunc } from "./common/translations/localize";
+import { ExternalMessaging } from "./external_app/external_messaging";
declare global {
var __DEV__: boolean;
@@ -109,7 +110,7 @@ export interface Resources {
}
export interface HomeAssistant {
- auth: Auth;
+ auth: Auth & { external?: ExternalMessaging };
connection: Connection;
connected: boolean;
states: HassEntities;
diff --git a/test-mocha/external_app/external_messaging.spec.ts b/test-mocha/external_app/external_messaging.spec.ts
new file mode 100644
index 0000000000..33e6d1b651
--- /dev/null
+++ b/test-mocha/external_app/external_messaging.spec.ts
@@ -0,0 +1,74 @@
+import * as assert from "assert";
+
+import {
+ ExternalMessaging,
+ InternalMessage,
+} from "../../src/external_app/external_messaging";
+
+class MockExternalMessaging extends ExternalMessaging {
+ public mockSent: InternalMessage[] = [];
+
+ protected _sendExternal(msg: InternalMessage) {
+ this.mockSent.push(msg);
+ }
+}
+
+describe("ExternalMessaging", () => {
+ let bus: MockExternalMessaging;
+
+ beforeEach(() => {
+ bus = new MockExternalMessaging();
+ });
+
+ it("Send success results", async () => {
+ const sendMessageProm = bus.sendMessage({
+ type: "config/get",
+ });
+
+ assert.equal(bus.mockSent.length, 1);
+ assert.deepEqual(bus.mockSent[0], {
+ id: 1,
+ type: "config/get",
+ });
+
+ bus.receiveMessage({
+ id: 1,
+ type: "result",
+ success: true,
+ result: {
+ hello: "world",
+ },
+ });
+
+ const result = await sendMessageProm;
+ assert.deepEqual(result, {
+ hello: "world",
+ });
+ });
+
+ it("Send fail results", async () => {
+ const sendMessageProm = bus.sendMessage({
+ type: "config/get",
+ });
+
+ bus.receiveMessage({
+ id: 1,
+ type: "result",
+ success: false,
+ error: {
+ code: "no_auth",
+ message: "There is no authentication.",
+ },
+ });
+
+ try {
+ await sendMessageProm;
+ assert.fail("Should have raised");
+ } catch (err) {
+ assert.deepEqual(err, {
+ code: "no_auth",
+ message: "There is no authentication.",
+ });
+ }
+ });
+});
diff --git a/translations/en.json b/translations/en.json
index afd3e713df..5b377e9c1c 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -924,6 +924,7 @@
}
},
"sidebar": {
+ "external_app_configuration": "App Configuration",
"log_out": "Log out",
"developer_tools": "Developer tools"
},
@@ -1181,4 +1182,4 @@
"system-users": "Users",
"system-read-only": "Read-Only Users"
}
-}
\ No newline at end of file
+}