Add external app message bus (#3112)

* Add support for a app configuration button in the sidebar

* Add event to types

* Fire connection events so that app knows when to hide its fallback settings button

* Add external message bus

* Fixes

* Update external_config.ts

* Remove icon from gen-icons

* Add fireMessagE

* msgId -> id

* Rename to externalBus

* Log messages in dev

* Add should update to ha-sidebar


Co-authored-by: Robbie Trencheny <me@robbiet.us>
This commit is contained in:
Paulus Schoutsen 2019-04-23 20:23:56 -07:00 committed by Robbie Trencheny
parent ad40d9927b
commit a5dd3755e1
8 changed files with 262 additions and 24 deletions

View File

@ -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 {
</a>
`
)}
${this._externalConfig && this._externalConfig.hasSettingsScreen
? html`
<a
href="#external-app-configuration"
tabindex="-1"
@click=${this._handleExternalAppConfiguration}
>
<paper-icon-item>
<ha-icon
slot="item-icon"
icon="hass:cellphone-settings-variant"
></ha-icon>
<span class="item-text"
>${hass.localize(
"ui.sidebar.external_app_configuration"
)}</span
>
</paper-icon-item>
</a>
`
: ""}
${!hass.user
? html`
<paper-icon-item @click=${this._handleLogOut} class="logout">
@ -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);
}

View File

@ -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)
)
: () =>

View File

@ -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<RefreshTokenResponse>(
(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<RefreshTokenResponse>(
(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);
});
}
}

View File

@ -0,0 +1,16 @@
import { ExternalMessaging } from "./external_messaging";
export interface ExternalConfig {
hasSettingsScreen: boolean;
}
export const getExternalConfig = (
bus: ExternalMessaging
): Promise<ExternalConfig> => {
if (!bus.cache.cfg) {
bus.cache.cfg = bus.sendMessage<ExternalConfig>({
type: "config/get",
});
}
return bus.cache.cfg;
};

View File

@ -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<T>(msg: InternalMessage): Promise<T> {
const msgId = ++this.msgId;
msg.id = msgId;
this.fireMessage(msg);
return new Promise<T>((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);
}
}
}

View File

@ -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;

View File

@ -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.",
});
}
});
});

View File

@ -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"
}
}
}