Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld d655705198 Say the browser is no longer supported instead of too old
With the option to continue it does run, so "too old to run Home Assistant"
contradicted the button. The same text now reads right before and after
continuing.
2026-11-10 15:57:30 +01:00
Maarten Lakerveld f43adfb69e Keep the browser warning up while the app loads
Once the user continued, the launch screen went blank until the legacy
build took over, which can take a while on these devices. Keep the message
and only drop the buttons.
2026-11-10 15:54:35 +01:00
Maarten Lakerveld 661c93a8ba Space the launch screen without max() on Chrome below 79
The launch screen's top spacer and OHF logo margins use max() with var()
inside. Chrome below 79 drops those after the cascade, leaving no margin,
so the logo touched the bottom edge (Chrome 67 to 78 are in the legacy
floor). Plain margins now come first, the max() rules behind @supports.
2026-11-10 15:40:20 +01:00
Maarten Lakerveld c00e961b14 Let browsers below the legacy floor continue at their own risk
Browsers below the floor get a "Continue anyway" button on the launch
screen instead of a dead end, e.g. for a wall dashboard. The warning returns
monthly, and from the first reminder on offers "Don't show again".
2026-11-10 15:26:01 +01:00
3 changed files with 315 additions and 19 deletions

No files matched your search

+39 -2
View File
@@ -45,15 +45,42 @@
}
#ha-launch-screen .ha-launch-screen-spacer-top {
flex: 1;
margin-top: calc( 2 * max(var(--safe-area-inset-top, 0px), 48px) + 46px );
margin-top: 142px;
padding-top: 48px;
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
flex: 1;
padding-top: 16px;
}
#ha-launch-screen-info-box p {
max-width: 400px;
margin: 0 16px 16px;
text-align: center;
}
/* Margins, not flex `gap` (Safari 14.1). */
#ha-launch-screen .ha-launch-screen-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
margin: -4px 16px;
}
#ha-launch-screen .ha-launch-screen-actions button {
margin: 4px;
padding: 8px 16px;
border: 1px solid transparent;
border-radius: 18px;
background: none;
color: var(--primary-color, #009ac7);
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
#ha-launch-screen .ha-launch-screen-actions .ha-launch-screen-continue {
border-color: currentColor;
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
margin: 48px 0;
display: flex;
flex-direction: column;
align-items: center;
@@ -73,6 +100,16 @@
aspect-ratio: 237 / 24;
height: auto;
}
/* max() with var() fails after the cascade, so plain fallbacks above
need @supports (Chrome below 79). `body`: Jinja2, see below. */
@supports (margin: max(1px, 2px)) {
body #ha-launch-screen .ha-launch-screen-spacer-top {
margin-top: calc( 2 * max(var(--safe-area-inset-top, 0px), 48px) + 46px );
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
}
}
@media (prefers-color-scheme: dark) {
/*
* Core renders the app's index.html through Jinja2, where the minified
+77 -17
View File
@@ -65,23 +65,83 @@
</script>
<script>
if (!window.latestJS) {
// Below the [legacy] browserslist floor the entry bundles cannot be
// parsed, or dialogs cannot open, and nothing would ever leave the
// launch screen. Test the features that set the floors: BigInt for
// Chrome 67, native <dialog> for Safari 15.4 and Firefox 98. Not
// transpiled, so keep this ES5.
if (
typeof BigInt === "undefined" ||
typeof document.createElement("dialog").showModal !== "function"
) {
document.getElementById("ha-launch-screen-info-box").textContent =
"This browser is too old to run Home Assistant.";
} else {
window.customPanelJS = "<%= es5CustomPanelJS %>";
<% for (const entry of es5EntryJS) { %>
_ls("<%= entry %>", true);
<% } %>
}
// Floor check: BigInt for Chrome 67, <dialog> for Safari 15.4 and
// Firefox 98. Not transpiled, so keep this ES5.
(function () {
var ACCEPTED_KEY = "haUnsupportedBrowserAccepted";
var REMIND_AFTER = 30 * 24 * 60 * 60 * 1000;
function loadApp() {
window.customPanelJS = "<%= es5CustomPanelJS %>";
<% for (const entry of es5EntryJS) { %>
_ls("<%= entry %>", true);
<% } %>
}
if (
typeof BigInt !== "undefined" &&
typeof document.createElement("dialog").showModal === "function"
) {
loadApp();
return;
}
// Shown while the app loads too.
var box = document.getElementById("ha-launch-screen-info-box");
var message = document.createElement("p");
message.textContent =
"This browser is no longer supported. Some parts of Home Assistant may not work.";
box.appendChild(message);
// Stored: "always", or when the user last continued (ms).
var stored = null;
try {
stored = localStorage.getItem(ACCEPTED_KEY);
} catch (_err) {
// Ask on every load.
}
if (stored === "always") {
loadApp();
return;
}
var acceptedAt = Number(stored) || 0;
// A clock behind the stored time asks again.
var age = Date.now() - acceptedAt;
if (acceptedAt && age >= 0 && age < REMIND_AFTER) {
loadApp();
return;
}
var actions = document.createElement("div");
actions.className = "ha-launch-screen-actions";
box.appendChild(actions);
function addButton(label, value) {
var button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.addEventListener("click", function () {
try {
localStorage.setItem(ACCEPTED_KEY, value());
} catch (_err) {
// Continue this time only.
}
box.removeChild(actions);
loadApp();
});
actions.appendChild(button);
return button;
}
addButton("Continue anyway", function () {
return String(Date.now());
}).className = "ha-launch-screen-continue";
if (acceptedAt) {
addButton("Don't show again", function () {
return "always";
});
}
})();
}
</script>
+199
View File
@@ -0,0 +1,199 @@
/**
* @vitest-environment node
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import template from "lodash.template";
import { describe, expect, it } from "vitest";
import patterns from "../../src/util/stale-build-patterns.json";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../.."
);
const ENTRY = "/frontend_es5/app.d1e2f3a4b5.js";
const ACCEPTED_KEY = "haUnsupportedBrowserAccepted";
// Renders a page template as build-scripts/gulp/entry-html.js does.
const renderTemplate = (templateFile, data) =>
template(readFileSync(templateFile, "utf-8"))({
...data,
renderTemplate: (childTemplate) =>
renderTemplate(
path.resolve(path.dirname(templateFile), childTemplate),
data
),
});
// The legacy browser check script from index.html.
const legacyCheckScript = () => {
const html = renderTemplate(
path.join(repoRoot, "src/html/index.html.template"),
{
modernRegex: "/modern/",
hassUrl: "",
staleBuildPatterns: patterns,
useCacheRecovery: true,
latestEntryJS: ["/frontend_latest/app.a1b2c3d4e5.js"],
es5EntryJS: [ENTRY],
latestCustomPanelJS: "/frontend_latest/custom-panel.e1f2a3b4c5.js",
es5CustomPanelJS: "/frontend_es5/custom-panel.f1a2b3c4d5.js",
}
);
return html
.split("<script>")
.map((part) => part.slice(0, part.indexOf("</script>")))
.find((part) => part.includes("haUnsupportedBrowserAccepted"));
};
const fakeElement = (tag) => {
const el = {
tag,
children: [],
listeners: {},
appendChild: (child) => {
el.children.push(child);
return child;
},
removeChild: (child) => {
el.children = el.children.filter((c) => c !== child);
return child;
},
addEventListener: (type, listener) => {
el.listeners[type] = listener;
},
};
let text = "";
Object.defineProperty(el, "textContent", {
get: () => text,
set: (value) => {
text = value;
el.children = [];
},
});
return el;
};
const DAY = 24 * 60 * 60 * 1000;
const NOW = Date.UTC(2026, 10, 10);
const run = ({
bigInt = false,
dialog = true,
stored,
now = NOW,
storageThrows = false,
}) => {
const started = [];
const box = fakeElement("div");
const store = stored ? { [ACCEPTED_KEY]: stored } : {};
const storage = {
getItem: (key) => {
if (storageThrows) throw new Error("SecurityError");
return key in store ? store[key] : null;
},
setItem: (key, value) => {
if (storageThrows) throw new Error("SecurityError");
store[key] = value;
},
};
const doc = {
createElement: (tag) =>
tag === "dialog"
? { showModal: dialog ? () => undefined : undefined }
: fakeElement(tag),
getElementById: (id) => (id === "ha-launch-screen-info-box" ? box : null),
};
// eslint-disable-next-line no-new-func
new Function(
"window",
"document",
"localStorage",
"BigInt",
"Date",
"_ls",
legacyCheckScript()
)(
{ latestJS: false },
doc,
storage,
bigInt ? () => undefined : undefined,
{ now: () => now },
(src) => started.push(src)
);
const buttons = Object.fromEntries(
(box.children.find((child) => child.tag === "div")?.children ?? []).map(
(button) => [button.textContent, button.listeners.click]
)
);
const shown = box.children.map((child) => child.tag);
return { started, box, store, buttons, shown };
};
describe("index.html legacy browser check", () => {
it("loads the app on a browser at the floor", () => {
const { started, box } = run({ bigInt: true, dialog: true });
expect(started).toEqual([ENTRY]);
expect(box.children).toEqual([]);
});
it.each([
["BigInt", { bigInt: false, dialog: true }],
["native dialog", { bigInt: true, dialog: false }],
])("offers to continue without %s", (_, browser) => {
const { started, box, store, buttons } = run(browser);
expect(started).toEqual([]);
expect(Object.keys(buttons)).toEqual(["Continue anyway"]);
buttons["Continue anyway"]();
expect(started).toEqual([ENTRY]);
expect(store[ACCEPTED_KEY]).toBe(String(NOW));
// The message stays up while the app loads.
expect(box.children.map((child) => child.tag)).toEqual(["p"]);
});
it("does not ask again within a month", () => {
const { started, shown } = run({ stored: String(NOW - 29 * DAY) });
expect(started).toEqual([ENTRY]);
expect(shown).toEqual(["p"]);
});
it("reminds after a month, and can be turned off", () => {
const stored = String(NOW - 30 * DAY);
const reminder = run({ stored });
expect(reminder.started).toEqual([]);
expect(Object.keys(reminder.buttons)).toEqual([
"Continue anyway",
"Don't show again",
]);
const continued = run({ stored });
continued.buttons["Continue anyway"]();
expect(continued.started).toEqual([ENTRY]);
expect(continued.store[ACCEPTED_KEY]).toBe(String(NOW));
reminder.buttons["Don't show again"]();
expect(reminder.started).toEqual([ENTRY]);
expect(reminder.store[ACCEPTED_KEY]).toBe("always");
expect(run({ stored: "always", now: NOW + 3650 * DAY }).started).toEqual([
ENTRY,
]);
});
// A tablet that lost its clock.
it("asks again when the clock is behind the stored time", () => {
const { started, buttons } = run({ stored: String(NOW + DAY) });
expect(started).toEqual([]);
expect(buttons["Continue anyway"]).toBeDefined();
});
it("still continues when storage is unavailable", () => {
const { started, buttons } = run({ storageThrows: true });
expect(started).toEqual([]);
buttons["Continue anyway"]();
expect(started).toEqual([ENTRY]);
});
});