mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-22 00:36:34 +00:00
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:
parent
ad40d9927b
commit
a5dd3755e1
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
)
|
||||
: () =>
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
16
src/external_app/external_config.ts
Normal file
16
src/external_app/external_config.ts
Normal 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;
|
||||
};
|
103
src/external_app/external_messaging.ts
Normal file
103
src/external_app/external_messaging.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
74
test-mocha/external_app/external_messaging.spec.ts
Normal file
74
test-mocha/external_app/external_messaging.spec.ts
Normal 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.",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user