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 +}