Dashboard Studio 1.14.1: Android app, phone menu button, install fix

- Android app (android/, built with build.sh): signs in to Home Assistant once, opens Dashboard Studio full screen, renews the ingress session; downloadable from Install as an app
- Phone dashboards: a round menu button brings back the top bar with Edit, Kiosk and Install as an app
- Install as an app explains what works per device (Android app, https for browser install, iPhone limits)
- Fixed: browser install failed because the manifest was fetched without the Home Assistant sign-in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 22:34:52 +01:00
parent 79d4a0f011
commit 297fb2a7a0
24 changed files with 561 additions and 26 deletions
+56 -20
View File
@@ -246,21 +246,50 @@
let installPrompt = null;
window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); installPrompt = e; });
const standalone = () => matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
// The Android app (android/ in the repository) shows this page in its own window and adds window.DSApp.
const IN_APP = !!window.DSApp;
const APK_ID = "nl.melvanveen.dashboardstudio";
async function installDialog() {
let inFrame = false;
try { inFrame = window.top !== window; } catch { inFrame = true; }
const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
const how = inFrame
? tr("Home Assistant’s page can’t be installed itself. Open Dashboard Studio in its own tab first, then choose Install as an app again there.")
: ios ? tr("In Safari, tap the Share button, then Add to Home Screen.")
: installPrompt ? tr("Your browser can install Dashboard Studio as an app with its own icon. It opens full screen, without the browser’s bars.")
: tr("In your browser’s menu, choose Install app or Add to Home screen.");
const choice = await openModal(`<h3>${tr("Install as an app")}</h3><p>${esc(how)}</p>
<p class="muted small">${tr("It opens on the dashboard chosen for that device. Mark buttons as a shortcut to use them from the app icon.")}</p>
<div class="modal-actions"><button class="btn" data-x="cancel">${tr("Close")}</button>${inFrame ? `<button class="btn primary" data-x="tab">${tr("Open in its own tab")}</button>` : !ios && installPrompt && !standalone() ? `<button class="btn primary" data-x="install">${tr("Install")}</button>` : ""}</div>`,
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); });
if (choice === "tab") window.open(new URL(`?ds=${current.id}`, BASE).href, "_blank", "noopener");
const ua = navigator.userAgent;
const ios = /iphone|ipad|ipod/i.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
const android = /android/i.test(ua);
const link = new URL(`?ds=${encodeURIComponent(current.id)}`, BASE).href;
const browserInstall = !inFrame && window.isSecureContext && installPrompt && !standalone();
const p = (t, cls = "") => `<p${cls ? ` class="${cls}"` : ""}>${esc(t)}</p>`;
let body, actions = `<button class="btn" data-x="cancel">${tr("Close")}</button>`;
if (IN_APP) {
body = p(tr("This is the Dashboard Studio app, connected to {0}.", location.host));
actions += `<button class="btn" data-x="reset">${tr("Change address")}</button>`;
} else if (android) {
// A home screen app from the browser needs https, and Home Assistant's own app can't make one at all: the Android app always works.
body = p(tr("Get the Dashboard Studio app for Android. It opens full screen with its own icon and keeps you signed in."))
+ `<ol class="steps"><li>${esc(tr("Download the app and open the file to install it. Android asks once to allow apps from this source."))}</li>
<li>${esc(tr("Come back here and tap Open in the app. Or open the app and enter the address of Home Assistant."))}</li></ol>`
+ (browserInstall ? p(tr("Or install it from the browser, without the app."), "muted small") : "");
actions += (browserInstall ? `<button class="btn" data-x="install">${tr("Install from the browser")}</button>` : "")
+ `<a class="btn" href="${new URL("android.apk", BASE).href}" download="Dashboard Studio.apk">${tr("Download the app")}</a>`
+ `<a class="btn primary" target="_top" href="intent://open?url=${encodeURIComponent(link)}#Intent;scheme=dashboardstudio;package=${APK_ID};end">${tr("Open in the app")}</a>`;
} else if (ios) {
// Home screen web apps on iPhone get their own storage, without Home Assistant's sign-in: they can't open an app inside Home Assistant.
body = p(tr("On iPhone and iPad, apps added to the home screen can’t use Home Assistant’s sign-in, so Dashboard Studio can’t run on its own there. Open it from the sidebar in the Home Assistant app instead."));
} else if (inFrame) {
body = p(tr("Home Assistant’s page can’t be installed itself. Open Dashboard Studio in its own tab first, then choose Install as an app again there."));
actions += `<button class="btn primary" data-x="tab">${tr("Open in its own tab")}</button>`;
} else if (!window.isSecureContext) {
body = p(tr("Browsers only install apps from a secure address (https), and Home Assistant is open over http here. Open Home Assistant through its https address, for example Home Assistant Cloud, and try again."));
} else {
body = p(browserInstall ? tr("Your browser can install Dashboard Studio as an app with its own icon. It opens full screen, without the browser’s bars.") : tr("In your browser’s menu, choose Install app or Add to Home screen."));
if (browserInstall) actions += `<button class="btn primary" data-x="install">${tr("Install")}</button>`;
}
const choice = await openModal(`<h3>${tr("Install as an app")}</h3>${body}
${IN_APP || ios ? "" : p(tr("It opens on the dashboard chosen for that device. Mark buttons as a shortcut to use them from the app icon."), "muted small")}
<div class="modal-actions">${actions}</div>`,
(e, close) => { const x = e.target.closest("[data-x], a.btn"); if (x) close(x.dataset.x || "link"); });
if (choice === "tab") window.open(link, "_blank", "noopener");
if (choice === "install" && installPrompt) { installPrompt.prompt(); installPrompt = null; }
if (choice === "reset") window.DSApp.reset();
}
/** Opened on its own (as an app, or in a tab), nobody renews Home Assistant's ingress session, so
@@ -770,7 +799,9 @@
const bg = t.preset !== "System" && t.bg ? t.bg : "var(--canvas)";
if (t.bgImage) s.setProperty("--d-bg", `center / cover no-repeat url("${t.bgImage.replace(/"/g, "")}"), ${bg}`);
s.setProperty("--d-radius", `${t.radius ?? 20}px`);
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", /^#[0-9a-f]{3,8}$/i.test(t.bg || "") && t.preset !== "System" ? t.bg : prefersDark() ? "#000000" : "#f2f2f7");
const bar = /^#[0-9a-f]{3,8}$/i.test(t.bg || "") && t.preset !== "System" ? t.bg : prefersDark() ? "#000000" : "#f2f2f7";
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", bar);
window.DSApp?.setBarColor(bar);
s.setProperty("--d-gap", `${Number(current.grid.gap) || 14}px`);
stageEl.classList.toggle("theme-outline", !!t.outline && t.preset !== "System");
s.setProperty("--d-blur", `${t.blur || 0}px`);
@@ -2450,6 +2481,7 @@
async function requestWakeLock() {
try { wakeLock = await navigator.wakeLock?.request("screen"); } catch { wakeLock = null; }
window.DSApp?.keepAwake(true); // the Android app has no wake lock: it keeps the screen on itself
}
const cornerName = () => (current.kiosk.exitCorner || "top-right").replace("-", " ");
@@ -2474,6 +2506,7 @@
closeDoorbell();
clearTimeout(idleTimer); clearTimeout(saverTimer);
wakeLock?.release?.(); wakeLock = null;
window.DSApp?.keepAwake(false);
if (document.fullscreenElement) document.exitFullscreen?.();
history.replaceState(null, "", location.pathname);
renderGrid();
@@ -2880,18 +2913,18 @@
canvasEl.addEventListener("pointerdown", (e) => {
if (!document.body.classList.contains("phone-ui") || e.target.closest(".w")) return;
holdTimer2 = setTimeout(() => {
document.body.classList.add("peek");
clearTimeout(peekTimer);
peekTimer = setTimeout(() => document.body.classList.remove("peek"), 10000);
peek(true);
navigator.vibrate?.(10);
}, 600);
});
for (const ev of ["pointerup", "pointercancel", "pointermove"]) canvasEl.addEventListener(ev, (e) => { if (ev !== "pointermove" || e.movementX || e.movementY) clearTimeout(holdTimer2); });
function phoneHint() {
if (!document.body.classList.contains("phone-ui") || lsGet("ds.peekHint")) return;
lsSet("ds.peekHint", "1");
toast(tr("Touch and hold an empty spot to show the menu."));
// The round button in the corner does the same, for dashboards without an empty spot.
function peek(on) {
document.body.classList.toggle("peek", on);
clearTimeout(peekTimer);
if (on) peekTimer = setTimeout(() => document.body.classList.remove("peek"), 15000);
}
$("#phone-menu").onclick = () => peek(!document.body.classList.contains("peek"));
// ------------------------------------------------------------ chrome wiring
function setCurrent(id) {
@@ -2954,6 +2987,8 @@
$("#btn-edit").onclick = () => setEdit(!editMode);
$("#btn-kiosk").onclick = () => enterKiosk();
$("#btn-app").onclick = () => installDialog();
$("#btn-app").hidden = IN_APP || standalone();
$("#btn-undo").onclick = undo;
$("#btn-redo").onclick = redo;
$("#dash-select").onchange = (e) => setCurrent(e.target.value);
@@ -3200,6 +3235,8 @@
$("#btn-redo").innerHTML = Icons.svg("redo");
$("#btn-menu").innerHTML = Icons.svg("more");
$("#btn-kiosk").insertAdjacentHTML("afterbegin", Icons.svg("kiosk"));
$("#btn-app").innerHTML = Icons.svg("phone");
$("#phone-menu").innerHTML = Icons.svg("more");
document.querySelectorAll("#menu [data-icon]").forEach((b) => b.insertAdjacentHTML("afterbegin", Icons.svg(b.dataset.icon)));
}
@@ -3272,7 +3309,6 @@
const exists = (id) => id && store.dashboards.some((d) => d.id === id);
setCurrent([hashId, dev?.dashboard, forUser, lsGet("ds.current")].find(exists));
if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
setTimeout(phoneHint, 1500);
const action = shared.get("ds_action");
if (action) { history.replaceState(null, "", location.pathname + location.hash); runShortcut(action); }
// After an update, point at what changed (once per device).