Compare commits

...
Author SHA1 Message Date
Bruno Pantaleão a9be1c91d3 Let companion apps draw the top bar back button
Apps that report hasNativeBackButton draw the back button themselves. The frontend hides its own arrow and reports whether the current top bar offers a back action, then performs the navigation when the app says the button was tapped.
2026-09-22 18:58:47 +01:00
9 changed files with 434 additions and 27 deletions
@@ -11,6 +11,7 @@ import { mainWindow } from "../common/dom/get_main_window";
import { navigate } from "../common/navigate";
import { showAutomationEditor } from "../data/automation";
import type { HomeAssistantMain } from "../layouts/home-assistant-main";
import { handleNativeBackButtonPressed } from "./external_back_button";
import type {
EMIncomingMessageBarCodeScanAborted,
EMIncomingMessageBarCodeScanResult,
@@ -99,6 +100,16 @@ export const handleExternalMessage = (
barCodeListeners.forEach((listener) => listener(msg));
} else if (msg.command === "kiosk_mode/set") {
fireEvent(window, "hass-kiosk-mode", { enable: msg.payload.enable });
} else if (msg.command === "back_button/pressed") {
if (!handleNativeBackButtonPressed()) {
bus.fireMessage({
id: msg.id,
type: "result",
success: false,
error: { code: "not_allowed", message: "no back button shown" },
});
return true;
}
} else {
return false;
}
+137
View File
@@ -0,0 +1,137 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import type { HomeAssistant } from "../types";
import type { ExternalMessaging } from "./external_messaging";
/*
Apps that draw their own toolbar tell us so with `hasNativeBackButton`. We then
hide our own back arrow and instead report whether the current top bar offers a
back action, so the app can show or hide its native button. Tapping that button
sends `back_button/pressed` back to us, so the navigation stays ours.
*/
interface Registration {
bus: ExternalMessaging;
back: () => void;
}
// Top bars that currently offer a back action. The last one to register owns
// the app's back button, so a page mounted on top of another one wins.
const registrations: Registration[] = [];
// The bus we last told to show the button, so we only report changes.
let shownOn: ExternalMessaging | undefined;
const sync = (): void => {
const active = registrations[registrations.length - 1];
if (active) {
if (shownOn !== active.bus) {
active.bus.fireMessage({ type: "back_button/show" });
shownOn = active.bus;
}
return;
}
if (shownOn) {
shownOn.fireMessage({ type: "back_button/hide" });
shownOn = undefined;
}
};
/**
* Run the back action of the top bar that currently owns the app's back
* button. Returns false when no top bar claims one, so the app can be told
* that its button was out of date.
*/
export const handleNativeBackButtonPressed = (): boolean => {
const active = registrations[registrations.length - 1];
if (!active) {
return false;
}
active.back();
return true;
};
interface NativeBackButtonHost extends ReactiveControllerHost {
hass?: HomeAssistant;
}
interface NativeBackButtonOptions {
/** Whether the top bar wants to offer a back action right now. */
visible: () => boolean;
/** Navigates back. Called for both our own arrow and the app's button. */
back: () => void;
}
/**
* Hands the back button of a top bar over to the external app when it renders
* one itself. Hosts must not render their own arrow while `native` is true.
*/
export class NativeBackButtonController implements ReactiveController {
private _registration?: Registration;
constructor(
private _host: NativeBackButtonHost,
private _options: NativeBackButtonOptions
) {
_host.addController(this);
}
/** True while the app renders the back button instead of us. */
public get native(): boolean {
return this._bus !== undefined;
}
private get _bus(): ExternalMessaging | undefined {
// Demo and gallery hosts get by with a partial hass, so tread carefully.
const external = this._host.hass?.auth?.external;
return external?.config.hasNativeBackButton ? external : undefined;
}
public hostConnected(): void {
this._sync();
}
public hostUpdated(): void {
this._sync();
}
public hostDisconnected(): void {
this._unregister();
}
private _sync(): void {
const bus = this._bus;
if (!bus || !this._options.visible()) {
this._unregister();
return;
}
if (this._registration) {
this._registration.bus = bus;
this._registration.back = this._options.back;
} else {
this._registration = { bus, back: this._options.back };
registrations.push(this._registration);
}
sync();
}
private _unregister(): void {
if (!this._registration) {
return;
}
const index = registrations.indexOf(this._registration);
if (index !== -1) {
registrations.splice(index, 1);
}
this._registration = undefined;
sync();
}
}
+18
View File
@@ -218,6 +218,14 @@ interface EMOutgoingMessageReloadAndClearCache extends EMMessage {
type: "frontend/reload_and_clear_cache";
}
interface EMOutgoingMessageBackButtonShow extends EMMessage {
type: "back_button/show"; // The top bar offers a back action; only sent with hasNativeBackButton
}
interface EMOutgoingMessageBackButtonHide extends EMMessage {
type: "back_button/hide";
}
// These types are handled internally by the Android app via postMessage.
// They are not sent by the frontend and should not be used directly.
// They are intentionally listed here to prevent anyone from using them unintentionally.
@@ -228,6 +236,8 @@ type EMOutgoingMessageWithoutAnswer =
| EMMessageResultSuccess
| EMOutgoingMessageAppConfiguration
| EMOutgoingMessageAssistShow
| EMOutgoingMessageBackButtonHide
| EMOutgoingMessageBackButtonShow
| EMOutgoingMessageBarCodeClose
| EMOutgoingMessageBarCodeNotify
| EMOutgoingMessageBarCodeScan
@@ -347,6 +357,12 @@ export interface EMIncomingMessageImprovDeviceSetupDone extends EMMessage {
command: "improv/device_setup_done";
}
export interface EMIncomingMessageBackButtonPressed {
id: number;
type: "command";
command: "back_button/pressed";
}
export interface EMIncomingMessageKioskModeSet {
id: number;
type: "command";
@@ -369,6 +385,7 @@ export interface EMIncomingMessageMatterCommissionFinish extends EMMessage {
}
export type EMIncomingMessageCommands =
| EMIncomingMessageBackButtonPressed
| EMIncomingMessageRestart
| EMIncomingMessageNavigate
| EMIncomingMessageShowNotifications
@@ -403,6 +420,7 @@ export interface ExternalConfig {
hasEntityAddTo?: boolean; // Supports "Add to" from more-info dialog, with action coming from external app
hasAssistSettings?: boolean; // Shows the "This device" section in voice assistant settings
hasSplashscreen?: boolean; // App covers the frontend with its own loading screen until frontend/loaded, so the launch screen is removed without animation
hasNativeBackButton?: boolean; // App draws the back button of the top bar itself, driven by back_button/show and back_button/hide
}
export interface ExternalEntityAddToAction {
+18 -9
View File
@@ -2,6 +2,22 @@ import { isNavigationClick } from "../common/dom/is-navigation-click";
import { goBack } from "../common/navigate";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
/**
* Navigate back the way the toolbar back arrow does, without a click to go by.
* Used by the external app when it renders the back button itself.
*/
export const navigateBack = (
backPath?: string,
backCallback?: () => void
): void => {
if (backCallback) {
backCallback();
return;
}
goBack(sanitizeNavigationPath(backPath));
};
/**
* Shared behavior of the toolbar back arrow. The arrow is a link to the
* declared parent page so it can be opened in a new tab, but a plain click
@@ -12,19 +28,12 @@ export const handleBackClick = (
backPath?: string,
backCallback?: () => void
): void => {
const path = sanitizeNavigationPath(backPath);
// Ctrl, cmd and shift click open the parent in a new tab or window: let
// the anchor handle those. A plain click is handled here instead, and
// isNavigationClick calls preventDefault so the anchor stays inert.
if (path && !isNavigationClick(ev)) {
if (sanitizeNavigationPath(backPath) && !isNavigationClick(ev)) {
return;
}
if (backCallback) {
backCallback();
return;
}
goBack(path);
navigateBack(backPath, backCallback);
};
+24 -9
View File
@@ -1,12 +1,13 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, eventOptions, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { restoreScroll } from "../common/decorators/restore-scroll";
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
import { getHistoryState } from "../common/navigate";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import { handleBackClick } from "./back-navigation";
import { NativeBackButtonController } from "../external_app/external_back_button";
import { handleBackClick, navigateBack } from "./back-navigation";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import { haStyleScrollbar } from "../resources/styles";
@@ -31,6 +32,18 @@ class HassSubpage extends LitElement {
// @ts-ignore
@restoreScroll(".content") private _savedScrollPos?: number;
private _nativeBackButton = new NativeBackButtonController(this, {
visible: () => this._showsBackButton,
back: () => navigateBack(this.backPath, this.backCallback),
});
private get _showsBackButton(): boolean {
return (
!this.mainPage &&
!(!sanitizeNavigationPath(this.backPath) && getHistoryState()?.root)
);
}
protected render(): TemplateResult {
const backPath = sanitizeNavigationPath(this.backPath);
@@ -38,14 +51,16 @@ class HassSubpage extends LitElement {
<div class="toolbar ${classMap({ narrow: this.narrow })}">
<div class="toolbar-content">
${
this.mainPage || (!backPath && getHistoryState()?.root)
!this._showsBackButton
? html`<ha-menu-button></ha-menu-button>`
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
: this._nativeBackButton.native
? nothing
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
}
<div class="main-title">
+23 -8
View File
@@ -17,7 +17,8 @@ import { isNavigationClick } from "../common/dom/is-navigation-click";
import { getHistoryState, navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import { handleBackClick } from "./back-navigation";
import { NativeBackButtonController } from "../external_app/external_back_button";
import { handleBackClick, navigateBack } from "./back-navigation";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import "../components/ha-svg-icon";
@@ -94,6 +95,18 @@ export class HassTabsSubpage extends LitElement {
// @ts-ignore
@restoreScroll(".content") private _savedScrollPos?: number;
private _nativeBackButton = new NativeBackButtonController(this, {
visible: () => this._showsBackButton,
back: () => navigateBack(this.backPath, this.backCallback),
});
private get _showsBackButton(): boolean {
return (
!this.mainPage &&
!(!sanitizeNavigationPath(this.backPath) && getHistoryState()?.root)
);
}
private _getTabs = memoizeOne(
(
tabs: PageNavigation[],
@@ -174,14 +187,16 @@ export class HassTabsSubpage extends LitElement {
<slot name="toolbar">
<div class="toolbar-content">
${
this.mainPage || (!backPath && getHistoryState()?.root)
!this._showsBackButton
? html`<ha-menu-button></ha-menu-button>`
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
: this._nativeBackButton.native
? nothing
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
}
${
this._narrow || !this.showTabs
@@ -16,6 +16,7 @@ import "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-top-app-bar-fixed";
import { NativeBackButtonController } from "../../external_app/external_back_button";
import "../../components/media-player/ha-media-manage-button";
import "../../components/media-player/ha-media-player-browse";
import type {
@@ -89,11 +90,16 @@ class PanelMediaBrowser extends LitElement {
@query("ha-bar-media-player") private _player!: BarMediaPlayer;
private _nativeBackButton = new NativeBackButtonController(this, {
visible: () => this._navigateIds.length > 1,
back: () => this._goBack(),
});
protected render(): TemplateResult {
return html`
<ha-top-app-bar-fixed .narrow=${this.narrow}>
${
this._navigateIds.length > 1
this._navigateIds.length > 1 && !this._nativeBackButton.native
? html`
<ha-icon-button-arrow-prev
slot="navigationIcon"
@@ -7,6 +7,7 @@ import {
handleExternalMessage,
addExternalBarCodeListener,
} from "../../src/external_app/external_app_entrypoint";
import { handleNativeBackButtonPressed } from "../../src/external_app/external_back_button";
import { showAutomationEditor } from "../../src/data/automation";
import type {
EMIncomingMessageRestart,
@@ -19,6 +20,7 @@ import type {
EMIncomingMessageImprovDeviceSetupDone,
EMIncomingMessageBarCodeScanResult,
EMIncomingMessageBarCodeScanAborted,
EMIncomingMessageBackButtonPressed,
} from "../../src/external_app/external_messaging";
vi.mock("../../src/common/dom/fire_event", () => ({
@@ -30,6 +32,9 @@ vi.mock("../../src/common/navigate", () => ({
vi.mock("../../src/data/automation", () => ({
showAutomationEditor: vi.fn(),
}));
vi.mock("../../src/external_app/external_back_button", () => ({
handleNativeBackButtonPressed: vi.fn(),
}));
describe("handleExternalMessage", () => {
let hassMainEl: any;
@@ -285,4 +290,38 @@ describe("handleExternalMessage", () => {
});
expect(result).toBe(true);
});
it("handles back_button/pressed command", () => {
vi.mocked(handleNativeBackButtonPressed).mockReturnValue(true);
const msg: EMIncomingMessageBackButtonPressed = {
type: "command",
command: "back_button/pressed",
id: 13,
};
const result = handleExternalMessage(hassMainEl, msg);
expect(handleNativeBackButtonPressed).toHaveBeenCalledOnce();
expect(fireMessage).toHaveBeenCalledWith({
id: 13,
type: "result",
success: true,
result: null,
});
expect(result).toBe(true);
});
it("reports back_button/pressed without a back button as an error", () => {
vi.mocked(handleNativeBackButtonPressed).mockReturnValue(false);
const msg: EMIncomingMessageBackButtonPressed = {
type: "command",
command: "back_button/pressed",
id: 14,
};
const result = handleExternalMessage(hassMainEl, msg);
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
id: 14,
type: "result",
success: false,
error: { code: "not_allowed", message: "no back button shown" },
});
expect(result).toBe(true);
});
});
@@ -0,0 +1,157 @@
import { LitElement } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { handleNativeBackButtonPressed } from "../../src/external_app/external_back_button";
import type { ExternalMessaging } from "../../src/external_app/external_messaging";
import type { HomeAssistant } from "../../src/types";
// The real back button pulls in the localize context, which is not provided here.
vi.mock("../../src/components/ha-icon-button-arrow-prev", () => ({}));
vi.mock("../../src/components/ha-menu-button", () => ({}));
vi.mock("../../src/common/navigate", () => ({
getHistoryState: () => undefined,
goBack: vi.fn(),
}));
customElements.define("ha-icon-button-arrow-prev", class extends LitElement {});
customElements.define("ha-menu-button", class extends LitElement {});
await import("../../src/layouts/hass-subpage");
const { goBack } = await import("../../src/common/navigate");
let fireMessage: ReturnType<typeof vi.fn>;
let nativeBus: ExternalMessaging;
// The app has a single external bus, so every page shares one.
const makeHass = (hasNativeBackButton: boolean): HomeAssistant =>
({
auth: {
external: hasNativeBackButton
? nativeBus
: ({
config: {},
fireMessage,
} as unknown as ExternalMessaging),
},
}) as HomeAssistant;
let host: HTMLDivElement | undefined;
const mount = async (hass: HomeAssistant, backPath = "/config") => {
const element = document.createElement("hass-subpage");
Object.assign(element, { hass, backPath });
host!.append(element);
await (element as LitElement).updateComplete;
return element;
};
const arrowOf = (element: Element) =>
element.shadowRoot!.querySelector("ha-icon-button-arrow-prev");
beforeEach(() => {
fireMessage = vi.fn();
nativeBus = {
config: { hasNativeBackButton: true },
fireMessage,
} as unknown as ExternalMessaging;
host = document.createElement("div");
document.body.append(host);
});
afterEach(() => {
host?.remove();
host = undefined;
vi.clearAllMocks();
});
describe("native back button", () => {
it("keeps rendering the arrow when the app has no native back button", async () => {
const element = await mount(makeHass(false));
expect(arrowOf(element)).not.toBeNull();
expect(fireMessage).not.toHaveBeenCalled();
expect(handleNativeBackButtonPressed()).toBe(false);
});
it("hides the arrow and reports the back button to the app", async () => {
const element = await mount(makeHass(true));
expect(arrowOf(element)).toBeNull();
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
type: "back_button/show",
});
});
it("hides the app back button once the page is gone", async () => {
const element = await mount(makeHass(true));
fireMessage.mockClear();
element.remove();
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
type: "back_button/hide",
});
});
it("does not report a back button on a main page", async () => {
const hass = makeHass(true);
const element = document.createElement("hass-subpage");
Object.assign(element, { hass, mainPage: true });
host!.append(element);
await (element as LitElement).updateComplete;
expect(fireMessage).not.toHaveBeenCalled();
expect(handleNativeBackButtonPressed()).toBe(false);
});
it("navigates back when the app reports a press", async () => {
await mount(makeHass(true), "/config/areas");
expect(handleNativeBackButtonPressed()).toBe(true);
expect(goBack).toHaveBeenCalledWith("/config/areas");
});
it("uses the back callback of the page when it has one", async () => {
const backCallback = vi.fn();
const element = await mount(makeHass(true));
Object.assign(element, { backCallback });
await (element as LitElement).updateComplete;
expect(handleNativeBackButtonPressed()).toBe(true);
expect(backCallback).toHaveBeenCalledOnce();
expect(goBack).not.toHaveBeenCalled();
});
it("lets the page mounted last own the back button", async () => {
const first = vi.fn();
const second = vi.fn();
const firstPage = await mount(makeHass(true));
Object.assign(firstPage, { backCallback: first });
await (firstPage as LitElement).updateComplete;
const secondPage = await mount(makeHass(true));
Object.assign(secondPage, { backCallback: second });
await (secondPage as LitElement).updateComplete;
handleNativeBackButtonPressed();
expect(second).toHaveBeenCalledOnce();
expect(first).not.toHaveBeenCalled();
// Back on the first page, it owns the button again.
secondPage.remove();
handleNativeBackButtonPressed();
expect(first).toHaveBeenCalledOnce();
});
it("only reports the back button once while pages come and go", async () => {
const firstPage = await mount(makeHass(true));
const secondPage = await mount(makeHass(true));
firstPage.remove();
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
type: "back_button/show",
});
secondPage.remove();
expect(fireMessage).toHaveBeenLastCalledWith({ type: "back_button/hide" });
});
});