mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-23 09:16:38 +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 { HomeAssistant, PanelInfo } from "../types";
|
||||||
import { fireEvent } from "../common/dom/fire_event";
|
import { fireEvent } from "../common/dom/fire_event";
|
||||||
import { DEFAULT_PANEL } from "../common/const";
|
import { DEFAULT_PANEL } from "../common/const";
|
||||||
|
import {
|
||||||
|
getExternalConfig,
|
||||||
|
ExternalConfig,
|
||||||
|
} from "../external_app/external_config";
|
||||||
|
|
||||||
const computeUrl = (urlPath) => `/${urlPath}`;
|
const computeUrl = (urlPath) => `/${urlPath}`;
|
||||||
|
|
||||||
@ -69,6 +73,7 @@ class HaSidebar extends LitElement {
|
|||||||
@property() public hass?: HomeAssistant;
|
@property() public hass?: HomeAssistant;
|
||||||
@property() public _defaultPage?: string =
|
@property() public _defaultPage?: string =
|
||||||
localStorage.defaultPage || DEFAULT_PANEL;
|
localStorage.defaultPage || DEFAULT_PANEL;
|
||||||
|
@property() private _externalConfig?: ExternalConfig;
|
||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
const hass = this.hass;
|
const hass = this.hass;
|
||||||
@ -117,6 +122,27 @@ class HaSidebar extends LitElement {
|
|||||||
</a>
|
</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
|
${!hass.user
|
||||||
? html`
|
? html`
|
||||||
<paper-icon-item @click=${this._handleLogOut} class="logout">
|
<paper-icon-item @click=${this._handleLogOut} class="logout">
|
||||||
@ -193,6 +219,9 @@ class HaSidebar extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||||
|
if (changedProps.has("_externalConfig")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (!this.hass || !changedProps.has("hass")) {
|
if (!this.hass || !changedProps.has("hass")) {
|
||||||
return false;
|
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() {
|
private _handleLogOut() {
|
||||||
fireEvent(this, "hass-logout");
|
fireEvent(this, "hass-logout");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _handleExternalAppConfiguration(ev: Event) {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.hass!.auth.external!.fireMessage({
|
||||||
|
type: "config_screen/show",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static get styles(): CSSResult {
|
static get styles(): CSSResult {
|
||||||
return css`
|
return css`
|
||||||
:host {
|
:host {
|
||||||
@ -259,7 +304,7 @@ class HaSidebar extends LitElement {
|
|||||||
--paper-item-min-height: 40px;
|
--paper-item-min-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
a ha-icon {
|
ha-icon[slot="item-icon"] {
|
||||||
color: var(--sidebar-icon-color);
|
color: var(--sidebar-icon-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@ const isExternal = location.search.includes("external_auth=1");
|
|||||||
|
|
||||||
const authProm = isExternal
|
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)
|
(mod) => new mod.default(hassUrl)
|
||||||
)
|
)
|
||||||
: () =>
|
: () =>
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
* Auth class that connects to a native app for authentication.
|
* Auth class that connects to a native app for authentication.
|
||||||
*/
|
*/
|
||||||
import { Auth } from "home-assistant-js-websocket";
|
import { Auth } from "home-assistant-js-websocket";
|
||||||
|
import { ExternalMessaging, InternalMessage } from "./external_messaging";
|
||||||
|
|
||||||
const CALLBACK_SET_TOKEN = "externalAuthSetToken";
|
const CALLBACK_SET_TOKEN = "externalAuthSetToken";
|
||||||
const CALLBACK_REVOKE_TOKEN = "externalAuthRevokeToken";
|
const CALLBACK_REVOKE_TOKEN = "externalAuthRevokeToken";
|
||||||
@ -20,6 +21,7 @@ declare global {
|
|||||||
externalApp?: {
|
externalApp?: {
|
||||||
getExternalAuth(payload: string);
|
getExternalAuth(payload: string);
|
||||||
revokeExternalAuth(payload: string);
|
revokeExternalAuth(payload: string);
|
||||||
|
externalBus(payload: string);
|
||||||
};
|
};
|
||||||
webkit?: {
|
webkit?: {
|
||||||
messageHandlers: {
|
messageHandlers: {
|
||||||
@ -29,6 +31,9 @@ declare global {
|
|||||||
revokeExternalAuth: {
|
revokeExternalAuth: {
|
||||||
postMessage(payload: BasePayload);
|
postMessage(payload: BasePayload);
|
||||||
};
|
};
|
||||||
|
externalBus: {
|
||||||
|
postMessage(payload: InternalMessage);
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -41,6 +46,8 @@ if (!window.externalApp && !window.webkit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default class ExternalAuth extends Auth {
|
export default class ExternalAuth extends Auth {
|
||||||
|
public external = new ExternalMessaging();
|
||||||
|
|
||||||
constructor(hassUrl) {
|
constructor(hassUrl) {
|
||||||
super({
|
super({
|
||||||
hassUrl,
|
hassUrl,
|
||||||
@ -51,19 +58,10 @@ export default class ExternalAuth extends Auth {
|
|||||||
// This will trigger connection to do a refresh right away
|
// This will trigger connection to do a refresh right away
|
||||||
expires: 0,
|
expires: 0,
|
||||||
});
|
});
|
||||||
|
this.external.attach();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async refreshAccessToken() {
|
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 };
|
const callbackPayload = { callback: CALLBACK_SET_TOKEN };
|
||||||
|
|
||||||
if (window.externalApp) {
|
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.access_token = tokens.access_token;
|
||||||
this.data.expires = tokens.expires_in * 1000 + Date.now();
|
this.data.expires = tokens.expires_in * 1000 + Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async revoke() {
|
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 };
|
const callbackPayload = { callback: CALLBACK_REVOKE_TOKEN };
|
||||||
|
|
||||||
if (window.externalApp) {
|
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,
|
HassServices,
|
||||||
} from "home-assistant-js-websocket";
|
} from "home-assistant-js-websocket";
|
||||||
import { LocalizeFunc } from "./common/translations/localize";
|
import { LocalizeFunc } from "./common/translations/localize";
|
||||||
|
import { ExternalMessaging } from "./external_app/external_messaging";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
var __DEV__: boolean;
|
var __DEV__: boolean;
|
||||||
@ -109,7 +110,7 @@ export interface Resources {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HomeAssistant {
|
export interface HomeAssistant {
|
||||||
auth: Auth;
|
auth: Auth & { external?: ExternalMessaging };
|
||||||
connection: Connection;
|
connection: Connection;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
states: HassEntities;
|
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": {
|
"sidebar": {
|
||||||
|
"external_app_configuration": "App Configuration",
|
||||||
"log_out": "Log out",
|
"log_out": "Log out",
|
||||||
"developer_tools": "Developer tools"
|
"developer_tools": "Developer tools"
|
||||||
},
|
},
|
||||||
|
Loading…
x
Reference in New Issue
Block a user