mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-10 15:04:37 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d655705198 | ||
|
|
f43adfb69e | ||
|
|
661c93a8ba | ||
|
|
c00e961b14 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user