Compare commits

..
Author SHA1 Message Date
Maarten Lakerveld 2edbab08ea Correct macOS floor for Safari 26 in companion app UA regex
Safari 26 ships for macOS 14 Sonoma and 15 Sequoia, not only macOS 26, so
the SAFARI_TO_MACOS entry breaking the minimum-supported-macOS pattern
would have sent updated macOS 14/15 companion apps to the legacy build
once the modern floor reaches Safari 26.
2026-08-31 17:06:55 +02:00
Maarten Lakerveld fa30c6b99a Repair list-plugins-and-polyfills script for Babel 8
The audit script died at startup since the Babel 8 update: preset-env no
longer exposes lib/debug.js (logPlugin is now inlined locally, built on the
public getInclusionReasons helper) and babel-plugin-polyfill-corejs3 v1 no
longer ships lib/shipped-proposals.js (list inlined).

Instead of invoking the preset with a hand-mocked plugin API - the part
that kept drifting - the plugin listing now runs a real transform of an
empty file with preset-env in debug mode, declaring the same caller
capabilities as babel-loader. The polyfill listing now passes the
configured core-js version to core-js-compat, mirroring the provider's own
filtering so the report cannot list modules the installed core-js lacks.
Output is byte-identical to the direct-invocation approach. Also documents
the script in the build-scripts README.
2026-08-31 17:06:55 +02:00
Maarten Lakerveld 77eebbe041 Remove dead html_url custom panel support
html_url pointed to an HTML Import, a Polymer-era feature removed from
Chrome in 2019 and never shipped elsewhere. The loader has not handled the
html type for years (it fell through to a rejection), and core's
panel_custom integration no longer accepts the option, so the branch was
unreachable. Also drops the ha-panel-${name} legacy tag naming that was
keyed on html_url.
2026-08-31 17:06:55 +02:00
Maarten Lakerveld 3ebec556ea Remove vendor prefixes for no-longer-supported browsers
Deletes -ms- prefixes (IE/EdgeHTML only) and -webkit-/-moz- prefixed
declarations that every supported browser understands unprefixed, or that
Lightning CSS re-adds automatically from the unprefixed property in
production builds. Blocks that only had prefixed user-select now use the
standard property (previously Firefox got no user-select there at all).
Converts the four -webkit-linear-gradient() declarations - the sole
gradient syntax on those sliders - to standard linear-gradient().
2026-08-31 17:06:55 +02:00
Maarten Lakerveld a4d19bc3b0 Remove old-browser JS shims and stale ES5 build references
Drops the IE-only navigator.msMaxTouchPoints check, replaces the
toggleAttribute helper with the native method (polyfilled automatically for
Chrome < 69 in the legacy build), and removes babel excludes for the
uninstalled proxy-polyfill and unfetch packages. Updates comments that
still described the legacy build as ES5.
2026-08-31 17:06:55 +02:00
Maarten Lakerveld 567f56421b Remove keyed-es5 Terser workaround
The custom keyed directive existed because Terser with ecma: 5 miscompiled
the destructured update() parameters (#28732). The legacy build now minifies
with ecma: 2017, so the stock lit-html keyed directive works in both builds.
2026-08-31 17:06:55 +02:00
Maarten Lakerveld a45c725301 Remove web components polyfills and ES5 custom elements adapter
All supported browsers (legacy floor: Chrome 59, Safari/iOS 12, Firefox 94)
have native shadow DOM and custom elements, so the webcomponents bundle,
ShadyCSS branch, and lit polyfill-support are unreachable. The legacy build
now emits ES2017 classes, so custom-elements-es5-adapter and the
window.loadES5Adapter hook (no known third-party consumers) are removed.
Also stops shipping the never-loaded dialog-polyfill css and drops both
now-unused dependencies.
2026-08-31 17:06:55 +02:00
80 changed files with 417 additions and 1154 deletions
+2
View File
@@ -75,3 +75,5 @@ test/e2e/app/dist/
test/benchmarks/results/
# Downloaded map glyph and sprite archives
.map-assets/
+9 -1
View File
@@ -34,6 +34,14 @@ In production, the following responsibilities are added:
- Minify HTML
- Bundle multiple imports so that the browser can fetch less files
- Generate a second version that is ES5 compatible
- Generate a second version that is compatible with older browsers (legacy build)
Configuration for all these steps are specified in [bundle.js](bundle.js).
## Auditing browser support changes
`node build-scripts/list-plugins-and-polyfills.js` prints, per browserslist
environment (modern/legacy), the Babel transforms preset-env enables and the
Core-JS polyfills that may be injected — as collapsible markdown ready to
paste into a PR. Use it to show the bundle impact when changing
`.browserslistrc` or the Babel configuration.
+1 -3
View File
@@ -121,7 +121,7 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
ignoreModuleNotFound: true,
},
],
// Import helpers and regenerator from runtime package.
// Import helpers from runtime package.
// `moduleName` is pinned so helpers resolve from `@babel/runtime`: the
// corejs3 polyfill provider above otherwise redirects them to the
// (uninstalled) `@babel/runtime-corejs3`, which preset-env used to suppress
@@ -155,8 +155,6 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
"@lit-labs/virtualizer/polyfills",
"@webcomponents/scoped-custom-element-registry",
"element-internals-polyfill",
"proxy-polyfill",
"unfetch",
].map((p) => new RegExp(`/node_modules/${p}/`)),
],
},
+2 -2
View File
@@ -25,7 +25,7 @@ const SAFARI_TO_MACOS = {
16: [11, 0, 0],
17: [12, 0, 0],
18: [13, 0, 0],
26: [26, 0, 0],
26: [14, 0, 0],
};
const getCommonTemplateVars = () => {
@@ -89,7 +89,7 @@ const minifyHtml = (content, ext) => {
...htmlMinifierOptions,
conservativeCollapse: false,
minifyJS: terserOptions({
latestBuild: false, // Shared scripts should be ES5
latestBuild: false, // Shared scripts must satisfy the legacy targets
isTestBuild: true, // Don't need source maps
}),
}).then((wrapped) =>
-35
View File
@@ -42,37 +42,6 @@ function copyMdiIcons(staticDir) {
fs.copySync(polyPath("build/mdi"), staticPath("mdi"));
}
function copyPolyfills(staticDir) {
const staticPath = genStaticPath(staticDir);
// For custom panels using ES5 builds that don't use Babel 7+
copyFileDir(
npmPath("@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"),
staticPath("polyfills/")
);
// Web Component polyfills and adapters
copyFileDir(
npmPath("@webcomponents/webcomponentsjs/webcomponents-bundle.js"),
staticPath("polyfills/")
);
copyFileDir(
npmPath("@webcomponents/webcomponentsjs/webcomponents-bundle.js.map"),
staticPath("polyfills/")
);
// Lit polyfill support
fs.copySync(
npmPath("lit/polyfill-support.js"),
path.join(staticPath("polyfills/"), "lit-polyfill-support.js")
);
// dialog-polyfill css
copyFileDir(
npmPath("dialog-polyfill/dialog-polyfill.css"),
staticPath("polyfills/")
);
}
function copyFonts(staticDir) {
const staticPath = genStaticPath(staticDir);
// Local fonts
@@ -141,7 +110,6 @@ gulp.task("copy-static-app", async () => {
const staticDir = paths.app_output_static;
// Basic static files
fs.copySync(polyPath("public"), paths.app_output_root);
copyPolyfills(staticDir);
copyFonts(staticDir);
copyTranslations(staticDir);
copyLocaleData(staticDir);
@@ -163,7 +131,6 @@ gulp.task("copy-static-demo", async () => {
);
// Copy demo static files
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
copyPolyfills(paths.demo_output_static);
await copyMapPanel(paths.demo_output_static);
copyFonts(paths.demo_output_static);
copyTranslations(paths.demo_output_static);
@@ -176,7 +143,6 @@ gulp.task("copy-static-cast", async () => {
fs.copySync(polyPath("public/static"), paths.cast_output_static);
// Copy cast static files
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_output_root);
copyPolyfills(paths.cast_output_static);
await copyMapPanel(paths.cast_output_static);
copyFonts(paths.cast_output_static);
copyTranslations(paths.cast_output_static);
@@ -223,7 +189,6 @@ gulp.task("copy-static-e2e-test-app", async () => {
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
}
copyPolyfills(paths.e2eTestApp_output_static);
await copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
+152 -20
View File
@@ -1,26 +1,137 @@
// Generates the MapLibre styles for the vector base map.
// Assembles the static assets of the vector base map - style, SDF glyphs and
// icon sprites - into /static/map/. vector.openstreetmap.org sets CORS headers
// on its tiles only, so these cannot be loaded from there by a browser.
//
// Only the styles. Glyphs, sprites and tiles are served by core's proxy, which
// is what lets them be requested with an application User-Agent and without a
// referrer. The styles stay here because they come from @versatiles/style and
// core has no node toolchain to regenerate them with.
// Glyphs and sprites come from pinned VersaTiles releases, cached locally and
// verified against a digest.
import { writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { colorful, eclipse } from "@versatiles/style";
import fs from "fs-extra";
import gulp from "gulp";
import { extract } from "tar";
import paths from "../paths.cjs";
import { addLatinLabels } from "./map-labels.js";
const PROXY_PATH = "/api/map_tiles";
const TILEJSON_URL = `${PROXY_PATH}/tilejson.json`;
// The tile URL is deliberately never named here: the OSMF asks consumers to
// resolve it through the TileJSON so they can move the tiles without every
// client needing a release. https://operations.osmfoundation.org/policies/vector/
const TILEJSON_URL =
"https://vector.openstreetmap.org/shortbread_v1/tilejson.json";
const ASSET_PATH = "/static/map";
const ARCHIVES = {
fonts: {
url: "https://github.com/versatiles-org/versatiles-fonts/releases/download/v2.2.0/noto_sans.tar.gz",
sha256: "a2dac39f4096722bc420367ffd4a36687cce7229e8aa760bc12cf657072eea6b",
},
sprites: {
url: "https://github.com/versatiles-org/versatiles-style/releases/download/v5.13.1/sprites.tar.gz",
sha256: "efffd0ee4cb9591bd52f16ff5b269d9618c7dd1db159cd6511943965560ddea5",
},
};
// Rendered with a device font through `localIdeographFontFamily`, so these are
// never downloaded - and they are 90% of the Noto Sans SDF set.
const LOCAL_IDEOGRAPH_BLOCKS = [
[0x2e80, 0x9fff], // CJK radicals through CJK Unified Ideographs, incl. kana
[0xac00, 0xd7ff], // Hangul syllables
[0xf900, 0xfaff], // CJK compatibility ideographs
[0xfe30, 0xfe4f], // CJK compatibility forms
];
// Our styles use bold only for motorway shields, so Latin, Greek and Cyrillic
// cover every ref and the rest of bold - half the glyph set - is dropped.
// `assertBoldStaysOnRefs` guards the assumption.
const BOLD_MAX_CODEPOINT = 0x04ff;
const BOLD_TEXT_FIELD = "{ref}";
const cacheDir = path.resolve(paths.root_dir, ".map-assets");
const outputDir = path.resolve(paths.build_dir, "map");
const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex");
// Downloads an archive into the cache, or reuses it when the digest matches.
const cachedArchive = async (name, { url, sha256: expected }) => {
const file = path.join(cacheDir, `${name}.tar.gz`);
if (await fs.pathExists(file)) {
if (sha256(await readFile(file)) === expected) {
return file;
}
console.warn("Cached map %s archive is stale, downloading again", name);
}
console.log("Downloading map %s from %s", name, url);
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to download map ${name}: ${response.status} ${response.statusText}`
);
}
const body = Buffer.from(await response.arrayBuffer());
const digest = sha256(body);
if (digest !== expected) {
throw new Error(
`Digest mismatch for map ${name}: expected ${expected}, got ${digest}`
);
}
await fs.outputFile(file, body);
return file;
};
const glyphRange = (entryPath) => {
const match = /^(?<font>[^/]+)\/(?<start>\d+)-(?<end>\d+)\.pbf$/.exec(
entryPath
);
return match
? { font: match.groups.font, start: Number(match.groups.start) }
: undefined;
};
const keepGlyph = (entryPath) => {
const range = glyphRange(entryPath);
if (!range) {
return false;
}
if (range.font.endsWith("_bold") && range.start > BOLD_MAX_CODEPOINT) {
return false;
}
return !LOCAL_IDEOGRAPH_BLOCKS.some(
([from, to]) => range.start >= from && range.start <= to
);
};
const keepSprite = (entryPath) =>
/^basics\/sprites(@2x)?\.(json|png)$/.test(entryPath);
// `neutrino`, for one, sets country and state labels in bold - names in any
// script, which would turn to tofu. Fail the build rather than ship that.
const assertBoldStaysOnRefs = (name, style) => {
const offenders = style.layers
.filter((layer) =>
(layer.layout?.["text-font"] ?? []).some((font) => font.endsWith("_bold"))
)
.filter((layer) => layer.layout["text-field"] !== BOLD_TEXT_FIELD)
.map((layer) => layer.id);
if (offenders.length) {
throw new Error(
`Style "${name}" uses bold for ${offenders.join(", ")}, which can hold ` +
`names in any script. Raise BOLD_MAX_CODEPOINT to cover the full set ` +
`before shipping this style.`
);
}
};
// MapLibre extends the fetched TileJSON with the style's source options, so
// anything left here wins and freezes at build time. Dropping them is what lets
// the proxy move the attribution and zoom range too, not just the URLs.
// a remote switch move the attribution and zoom range too, not just the URLs.
const TILEJSON_FIELDS = [
"tiles",
"attribution",
@@ -53,25 +164,46 @@ const useTileJson = (name, style) => {
const styleOptions = {
// Keeps the generated URLs origin relative.
baseUrl: "",
glyphs: `${PROXY_PATH}/fonts/{fontstack}/{range}.pbf`,
sprite: [{ id: "basics", url: `${PROXY_PATH}/sprites/basics/sprites` }],
glyphs: `${ASSET_PATH}/fonts/{fontstack}/{range}.pbf`,
sprite: [{ id: "basics", url: `${ASSET_PATH}/sprites/basics/sprites` }],
};
const buildMapAssets = async () => {
await fs.emptyDir(outputDir);
const [fontArchive, spriteArchive] = await Promise.all([
cachedArchive("fonts", ARCHIVES.fonts),
cachedArchive("sprites", ARCHIVES.sprites),
]);
await Promise.all(
await fs.emptyDir(outputDir);
await Promise.all([
fs.ensureDir(path.join(outputDir, "fonts")),
fs.ensureDir(path.join(outputDir, "sprites")),
]);
await Promise.all([
extract({
file: fontArchive,
cwd: path.join(outputDir, "fonts"),
filter: keepGlyph,
}),
extract({
file: spriteArchive,
cwd: path.join(outputDir, "sprites"),
filter: keepSprite,
}),
// Both themes up front: dark is a real cartography, not an inverted raster.
[
...[
["light", colorful],
["dark", eclipse],
].map(([name, builder]) =>
writeFile(
].map(([name, builder]) => {
const style = addLatinLabels(useTileJson(name, builder(styleOptions)));
assertBoldStaysOnRefs(name, style);
return writeFile(
path.join(outputDir, `${name}.json`),
JSON.stringify(addLatinLabels(useTileJson(name, builder(styleOptions))))
)
)
);
JSON.stringify(style)
);
}),
]);
};
// Shared so it does not have to be wired into every pipeline separately.
+62 -27
View File
@@ -1,38 +1,55 @@
#!/usr/bin/env node
// Script to print Babel plugins and Core JS polyfills that will be used by browserslist environments
import { version as babelVersion } from "@babel/core";
import presetEnv from "@babel/preset-env";
import compilationTargets from "@babel/helper-compilation-targets";
import { transformSync } from "@babel/core";
import compilationTargets, {
getInclusionReasons,
} from "@babel/helper-compilation-targets";
import coreJSCompat from "core-js-compat";
import { logPlugin } from "@babel/preset-env/lib/debug.js";
import shippedPolyfills from "../node_modules/babel-plugin-polyfill-corejs3/lib/shipped-proposals.js";
import { babelOptions } from "./bundle.cjs";
const detailsOpen = (heading) =>
`<details>\n<summary><h4>${heading}</h4></summary>\n`;
const detailsClose = "</details>\n";
const dummyAPI = {
version: babelVersion,
// eslint-disable-next-line @typescript-eslint/no-empty-function
assertVersion: () => {},
caller: (callback) =>
callback({
name: "Dummy Bundler",
supportsStaticESM: true,
supportsDynamicImport: true,
supportsTopLevelAwait: true,
supportsExportNamespaceFrom: true,
}),
targets: () => ({}),
// Copied from @babel/preset-env's internal `logPlugin`, which Babel 8 no
// longer exposes (the package rolls up into lib/index.js and exports nothing
// but the preset). Prints an item with the targets that require it.
const logPlugin = (item, targetVersions, list) => {
const filteredList = getInclusionReasons(item, targetVersions, list);
const support = list[item];
if (!support) {
console.log(` ${item}`);
return;
}
let formattedTargets = `{`;
let first = true;
for (const target of Object.keys(filteredList)) {
if (!first) formattedTargets += `,`;
first = false;
formattedTargets += ` ${target}`;
if (support[target]) formattedTargets += ` < ${support[target]}`;
}
formattedTargets += ` }`;
console.log(` ${item} ${formattedTargets}`);
};
// Copied from babel-plugin-polyfill-corejs3's generated
// corejs3ShippedProposalsList, which v1 no longer exposes (it is inlined in
// the package's rolled-up bundle).
const shippedProposalsList = new Set([
"esnext.array.group",
"esnext.array.group-to-map",
"esnext.iterator.zip",
"esnext.iterator.zip-keyed",
"esnext.symbol.metadata",
]);
// Generate filter function based on proposal/method inputs
// Copied and adapted from babel-plugin-polyfill-corejs3/esm/index.mjs
const polyfillFilter = (method, proposals, shippedProposals) => (name) => {
if (proposals || method === "entry-global") return true;
if (shippedProposals && shippedPolyfills.default.has(name)) {
if (shippedProposals && shippedProposalsList.has(name)) {
return true;
}
if (name.startsWith("esnext.")) {
@@ -47,7 +64,9 @@ const polyfillFilter = (method, proposals, shippedProposals) => (name) => {
for (const buildType of ["Modern", "Legacy"]) {
const browserslistEnv = buildType.toLowerCase();
const babelOpts = babelOptions({ latestBuild: browserslistEnv === "modern" });
const presetEnvOpts = babelOpts.presets[0][1];
const presetEnvOpts = babelOpts.presets.find(
(preset) => Array.isArray(preset) && preset[0] === "@babel/preset-env"
)?.[1];
// Core-JS polyfills are injected by babel-plugin-polyfill-corejs3 (Babel 8
// removed preset-env's `useBuiltIns`), so read its options here.
const corejsOpts = babelOpts.plugins.find(
@@ -55,22 +74,38 @@ for (const buildType of ["Modern", "Legacy"]) {
Array.isArray(plugin) && plugin[0] === "babel-plugin-polyfill-corejs3"
)?.[1];
// Invoking preset-env in debug mode will log the included plugins
// Transforming an empty file with preset-env in debug mode logs the included
// plugins. The caller declares the same capabilities babel-loader does, so
// plugins gated on bundler support (e.g. transform-export-namespace-from)
// match the build.
presetEnvOpts.debug = true;
console.log(detailsOpen(`${buildType} Build Babel Plugins`));
presetEnv.default(dummyAPI, {
...presetEnvOpts,
browserslistEnv,
debug: true,
transformSync("", {
...babelOpts,
configFile: false,
filename: "audit.js",
caller: {
name: "list-plugins-and-polyfills",
supportsStaticESM: true,
supportsDynamicImport: true,
supportsTopLevelAwait: true,
supportsExportNamespaceFrom: true,
},
});
console.log(detailsClose);
// Manually log the Core-JS polyfills using the same technique
if (corejsOpts) {
console.log(detailsOpen(`${buildType} Build Core-JS Polyfills`));
const targets = compilationTargets.default(babelOpts?.targets, {
const targets = compilationTargets(babelOpts.targets, {
browserslistEnv,
});
const polyfillList = coreJSCompat({ targets }).list.filter(
// `version` limits the list to modules the installed core-js ships,
// mirroring the provider's own filtering.
const polyfillList = coreJSCompat({
targets,
version: corejsOpts.version,
}).list.filter(
polyfillFilter(
corejsOpts.method,
corejsOpts.proposals,
+1 -4
View File
@@ -345,10 +345,7 @@ const createRspackConfig = ({
"lit/directives/join$": "lit/directives/join.js",
"lit/directives/repeat$": "lit/directives/repeat.js",
"lit/directives/live$": "lit/directives/live.js",
"lit/directives/keyed$": latestBuild
? "lit/directives/keyed.js"
: path.resolve(__dirname, "../src/common/lit/keyed-es5.ts"),
"lit/polyfill-support$": "lit/polyfill-support.js",
"lit/directives/keyed$": "lit/directives/keyed.js",
"@lit-labs/virtualizer/layouts/grid":
"@lit-labs/virtualizer/layouts/grid.js",
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver":
+1 -3
View File
@@ -15,7 +15,6 @@ import {
saveTokens,
} from "../../../../src/common/auth/token_storage";
import { atLeastVersion } from "../../../../src/common/config/version";
import { toggleAttribute } from "../../../../src/common/dom/toggle_attribute";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-icon";
import "../../../../src/components/ha-list";
@@ -197,8 +196,7 @@ class HcCast extends LitElement {
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
toggleAttribute(
this,
this.toggleAttribute(
"hide-icons",
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
);
+1 -3
View File
@@ -693,9 +693,7 @@ class HaGallery extends LitElement {
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
user-select: initial;
--ha-sidebar-width: 300px;
--ha-sidebar-expanded-width: 300px;
--ha-sidebar-expanded-item-width: 292px;
-2
View File
@@ -90,7 +90,6 @@
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.2",
"cally": "0.9.2",
"color-name": "2.1.1",
@@ -101,7 +100,6 @@
"date-fns": "4.4.0",
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"echarts-extension-chart2music": "0.1.1",
"element-internals-polyfill": "3.0.2",
@@ -33,7 +33,6 @@ export const filterPanelStyles = css`
--ha-card-border-radius: var(--ha-border-radius-square);
}
ha-expansion-panel::part(summary) {
-webkit-user-select: none;
user-select: none;
}
.content {
+5 -10
View File
@@ -145,16 +145,11 @@ export const applyThemesOnElement = (
element.__themes = { cacheKey, keys: newTheme?.keys };
// Set and/or reset styles
if (window.ShadyCSS) {
// Use ShadyCSS if available
window.ShadyCSS.styleSubtree(/** @type {!HTMLElement} */ element, styles);
} else {
for (const s in styles) {
if (s === null) {
element.style.removeProperty(s);
} else {
element.style.setProperty(s, styles[s]);
}
for (const s in styles) {
if (s === null) {
element.style.removeProperty(s);
} else {
element.style.setProperty(s, styles[s]);
}
}
};
+1 -3
View File
@@ -19,7 +19,6 @@ export const setupLeafletMap = async (
longitude: number;
zoom?: number;
darkMode?: boolean;
token?: string;
}
): Promise<LeafletMapSetup> => {
if (!mapElement.parentNode) {
@@ -61,8 +60,7 @@ export const setupLeafletMap = async (
const baseLayer = await createBaseLayer(
Leaflet,
map,
initialView?.darkMode ?? false,
initialView?.token
initialView?.darkMode ?? false
);
return { map, leaflet: Leaflet, baseLayer };
-25
View File
@@ -1,25 +0,0 @@
// Toggle Attribute Polyfill because it's too new for some browsers
export const toggleAttribute = (
el: HTMLElement,
name: string,
force?: boolean
) => {
if (force !== undefined) {
force = !!force;
}
if (el.hasAttribute(name)) {
if (force) {
return true;
}
el.removeAttribute(name);
return false;
}
if (force === false) {
return false;
}
el.setAttribute(name, "");
return true;
};
@@ -1 +0,0 @@
export const webComponentsSupported = "attachShadow" in Element.prototype;
-50
View File
@@ -1,50 +0,0 @@
/**
* ES5-compatible implementation of the keyed directive.
* Based on lit-html's keyed directive but written to avoid ES5 minification issues.
*
* This implementation avoids parameter destructuring in the update() method,
* which causes Terser with ecma: 5 to generate invalid references like `_k`.
*
* Used only for ES5 builds (legacy browsers). Modern builds use the original
* lit-html keyed directive.
*
* @see https://github.com/home-assistant/frontend/issues/28732
*/
import { directive, Directive } from "lit-html/directive.js";
import { setCommittedValue } from "lit-html/directive-helpers.js";
// eslint-disable-next-line lit/no-legacy-imports
import { nothing } from "lit-html";
import type { Part } from "lit-html/directive.js";
class KeyedES5 extends Directive {
private _key: unknown = nothing;
render(k: unknown, v: unknown) {
this._key = k;
return v;
}
update(part: unknown, args: [unknown, unknown]) {
const k = args[0];
const v = args[1];
if (k !== this._key) {
// Clear the part before returning a value. The one-arg form of
// setCommittedValue sets the value to a sentinel which forces a
// commit the next render.
setCommittedValue(part as Part);
this._key = k;
}
return v;
}
}
/**
* Associates a renderable value with a unique key. When the key changes, the
* previous DOM is removed and disposed before rendering the next value, even
* if the value - such as a template - is the same.
*
* This is useful for forcing re-renders of stateful components, or working
* with code that expects new data to generate new HTML elements, such as some
* animation techniques.
*/
export const keyed = directive(KeyedES5);
+67 -142
View File
@@ -1,16 +1,13 @@
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
import type { Map as LeafletMap, TileLayerOptions } from "leaflet";
import type { Map as LeafletMap } from "leaflet";
import type { setRTLTextPlugin, StyleSpecification } from "maplibre-gl";
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
import {
MAP_TILES_PATH,
refreshMapTilesToken,
subscribeMapTilesToken,
withMapTilesToken,
} from "../../data/map_tiles";
// Generated by build-scripts/gulp/map-assets.js. The attribution comes from the
// TileJSON, deliberately: it follows whoever serves the tiles.
// Shortbread vector tiles from the OpenStreetMap Foundation. Only their tile
// endpoint sends CORS headers, so the style, glyphs and sprites are ours to
// serve - see build-scripts/gulp/map-assets.js. The credit comes from the
// TileJSON rather than from here, deliberately: it follows whoever serves the
// tiles.
const VECTOR_STYLES = {
light: "/static/map/light.json",
dark: "/static/map/dark.json",
@@ -20,31 +17,25 @@ const VECTOR_STYLES = {
// worker, hence a URL rather than an import.
const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
// OSM serves no @2x variant.
const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`;
// The demo has no proxy to go through. Upstream serves raster to a browser that
// identifies itself with a referrer, which the demo page's `same-origin` meta
// policy strips again unless the tiles ask for it back.
const DEMO_RASTER_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const OSM_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
// Fallback for browsers without WebGL2, which MapLibre needs even for raster,
// so it stays a Leaflet tile layer. Still CARTO, and temporarily so: OSM's
// raster blocks a browser that sends no Referer, and the only referrer a browser
// can send is its origin, which identifies a Nabu Casa installation.
const RASTER_TILE_URL = "https://basemaps.cartocdn.com/rastertiles/voyager";
const CARTO_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, ' +
'&copy; <a href="https://carto.com/attributions">CARTO</a>';
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
// Browsers keep about 16 live WebGL contexts and drop the oldest, so a dashboard
// full of map cards loses its first ones for good - nothing frees a slot for
// MapLibre to reclaim. A transient loss does get restored, hence the grace.
const CONTEXT_RESTORE_GRACE = 2000;
const RECOVERY_THROTTLE = 30000;
// On the map, not the layer: marker clustering throws without a maximum. The
// floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1.
// On the map, not the layer: only Leaflet tile layers report their own limits,
// and marker clustering throws without a maximum. The floor is 1 because at
// Leaflet zoom 0 the adapter drives MapLibre to -1, outside its range.
export const MAP_MIN_ZOOM = 1;
export const MAP_MAX_ZOOM = 20;
// OSM's raster stops at 19 and the proxy refuses higher, so Leaflet scales the
// last level up rather than asking for tiles that are not there.
const RASTER_MAX_NATIVE_ZOOM = 19;
// Leaflet substitutes any option into the URL template; its types do not.
type TokenTileLayerOptions = TileLayerOptions & { token?: string };
export interface MapBaseLayer {
// A no-op for raster, which has no dark variant and is inverted in CSS.
@@ -53,7 +44,7 @@ export interface MapBaseLayer {
let webGL2Supported: boolean | undefined;
// Rules out iOS below 15, older Android tablets and blocklisted drivers.
// Rules out iOS below 15, older Android tablets, and blocklisted drivers.
const supportsWebGL2 = (): boolean => {
if (webGL2Supported === undefined) {
try {
@@ -68,35 +59,11 @@ const supportsWebGL2 = (): boolean => {
return webGL2Supported;
};
// MapLibre rejects a relative sprite URL. Not the glyph URL: encoding would
// mangle its {fontstack} and {range} placeholders.
// The demo has no core to proxy through. OSM sets CORS on its tiles but not on
// its glyphs and sprites, which is why those come from VersaTiles.
const DEMO_UPSTREAM = {
tilejson: "https://vector.openstreetmap.org/shortbread_v1/tilejson.json",
assets: "https://tiles.versatiles.org/assets",
};
const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
style.glyphs = `${DEMO_UPSTREAM.assets}/glyphs/{fontstack}/{range}.pbf`;
style.sprite = [
{ id: "basics", url: `${DEMO_UPSTREAM.assets}/sprites/basics/sprites` },
];
Object.values(style.sources).forEach((source) => {
if ("url" in source) {
source.url = DEMO_UPSTREAM.tilejson;
}
});
return style;
};
// Asset URLs are stored origin relative so they follow the instance's host, but
// MapLibre rejects a relative sprite URL. The glyph URL is left alone: URL
// encoding would mangle its {fontstack} and {range} placeholders.
const loadStyle = async (url: string): Promise<StyleSpecification> => {
const style: StyleSpecification = await (await fetch(url)).json();
if (__DEMO__) {
return useDemoUpstream(style);
}
if (typeof style.sprite === "string") {
style.sprite = new URL(style.sprite, location.href).href;
} else if (Array.isArray(style.sprite)) {
@@ -126,24 +93,21 @@ const createVectorLayer = async (
createLayer: typeof maplibreGL,
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
token: string | undefined
darkMode: boolean
): Promise<MapBaseLayer | undefined> => {
let layer: ReturnType<typeof maplibreGL> | undefined;
try {
layer = createLayer({
style: await loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"]),
// Absolute, or the worker fetching tiles cannot resolve them.
transformRequest: (url) => ({
url: withMapTilesToken(url),
// OSM asks a website for a referrer, and the demo has no instance
// hostname to leak.
referrerPolicy: __DEMO__ ? "origin" : undefined,
}),
// Draws CJK, kana and hangul with a device font, which is why we ship a
// tenth of the glyph set. No referrer is set: the endpoint serves without
// one, and the only one a browser can send identifies the installation.
localIdeographFontFamily: "sans-serif",
});
// The plugin builds the MapLibre map in `onAdd`, so a refused context or a
// blocked worker throws here. Keep it guarded or those lose the fallback.
// The plugin builds the MapLibre map in `onAdd`, so a refused context, a
// blocked worker or a rejected blob URL throws here. Keep it inside the
// guard or those lose the raster fallback.
layer.addTo(map);
} catch {
if (layer) {
@@ -156,14 +120,14 @@ const createVectorLayer = async (
return undefined;
}
// Tracked apart so a failed request rolls back to what is displayed, not to
// the opposite of what it asked - which with several in flight differs.
// Tracked apart because a failed request must roll back to what is displayed,
// not to the opposite of what it asked for - with several in flight those are
// different, and guessing wrong makes the next toggle a permanent no-op.
let appliedDarkMode = darkMode;
let requestedDarkMode = darkMode;
// Styles are fetched, so only the newest request may touch the map.
let latestRequest = 0;
let vector = true;
let refused = false;
const glMap = layer.getMaplibreMap();
let fallbackTimeout: number | undefined;
@@ -184,12 +148,13 @@ const createVectorLayer = async (
} catch {
// Nothing left to detach.
}
createRasterLayer(leaflet, map, token);
createRasterLayer(leaflet, map);
};
const scheduleSwap = () => {
clearTimeout(fallbackTimeout);
// Backgrounding drops it too, and there it comes back on return.
// Backgrounding also drops the context, and there it comes back on return.
// Running the clock then would make switching apps enough to lose vector.
if (!vector || document.hidden) {
return;
}
@@ -212,93 +177,54 @@ const createVectorLayer = async (
document.removeEventListener("visibilitychange", handleVisibilityChange);
});
const applyStyle = (newDarkMode: boolean) => {
const request = ++latestRequest;
loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"])
.then((style) => {
if (request === latestRequest) {
appliedDarkMode = newDarkMode;
layer.getMaplibreMap()?.setStyle(style);
}
})
.catch(() => {
if (request === latestRequest) {
requestedDarkMode = appliedDarkMode;
}
});
};
// A refused request leaves the source dead: the TileJSON is fetched once and
// is never retried, so the style has to be applied again once there is a new
// token. Throttled, or a proxy refusing for another reason loops.
let lastRecovery = 0;
glMap.on("error", (event) => {
if ((event.error as { status?: number } | undefined)?.status !== 403) {
return;
}
if (Date.now() - lastRecovery < RECOVERY_THROTTLE) {
return;
}
lastRecovery = Date.now();
refused = true;
refreshMapTilesToken();
});
// Only a new token clears the refusal. A theme change in between applies a
// style that is refused just as the last one was, so it proves nothing.
const unsubscribeToken = subscribeMapTilesToken(() => {
if (vector && refused) {
refused = false;
applyStyle(requestedDarkMode);
}
});
map.on("unload", unsubscribeToken);
return {
setDarkMode: (newDarkMode: boolean) => {
if (!vector || newDarkMode === requestedDarkMode) {
return;
}
requestedDarkMode = newDarkMode;
applyStyle(newDarkMode);
const request = ++latestRequest;
loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"])
.then((style) => {
if (request === latestRequest) {
appliedDarkMode = newDarkMode;
layer.getMaplibreMap()?.setStyle(style);
}
})
.catch(() => {
if (request === latestRequest) {
requestedDarkMode = appliedDarkMode;
}
});
},
};
};
const createRasterLayer = (
leaflet: LeafletModuleType,
map: LeafletMap,
token: string | undefined
map: LeafletMap
): MapBaseLayer => {
const layer = leaflet
.tileLayer(__DEMO__ ? DEMO_RASTER_TILE_URL : RASTER_TILE_URL, {
attribution: OSM_ATTRIBUTION,
maxZoom: MAP_MAX_ZOOM,
maxNativeZoom: RASTER_MAX_NATIVE_ZOOM,
referrerPolicy: __DEMO__ ? "origin" : undefined,
// Leaflet throws on an undefined template variable, so no token means an
// empty one: the tiles 403 and the markers still draw.
token: token ?? "",
} as TokenTileLayerOptions)
leaflet
.tileLayer(
// These are the old retina tablets, and CARTO serves @2x - sharp tiles at
// the same request count, where `detectRetina` would fetch a zoom deeper
// at four times as many.
`${RASTER_TILE_URL}/{z}/{x}/{y}${leaflet.Browser.retina ? "@2x" : ""}.png`,
{
attribution: CARTO_ATTRIBUTION,
maxZoom: MAP_MAX_ZOOM,
}
)
.addTo(map);
// Substituted per request, so a refreshed token needs no new layer.
const unsubscribe = subscribeMapTilesToken((newToken) => {
(layer.options as TokenTileLayerOptions).token = newToken;
// Tiles that 403'd are cached as failures; only a redraw asks again.
layer.redraw();
});
map.on("unload", unsubscribe);
return { setDarkMode: () => undefined };
};
export const createBaseLayer = async (
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
token: string | undefined
darkMode: boolean
): Promise<MapBaseLayer> => {
if (supportsWebGL2()) {
let vectorLayer: MapBaseLayer | undefined;
@@ -312,8 +238,7 @@ export const createBaseLayer = async (
createLayer,
leaflet,
map,
darkMode,
token
darkMode
);
} catch {
// No chunk, no vector map - but still a map.
@@ -322,5 +247,5 @@ export const createBaseLayer = async (
return vectorLayer;
}
}
return createRasterLayer(leaflet, map, token);
return createRasterLayer(leaflet, map);
};
+1 -3
View File
@@ -1235,10 +1235,8 @@ export class HaDataTable extends LitElement {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.mdc-data-table__header-row {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
scrollbar-width: none;
}
.mdc-data-table__cell,
-2
View File
@@ -293,12 +293,10 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
);
overflow: hidden;
color: var(--primary-text-color);
-webkit-backdrop-filter: var(
--ha-dialog-surface-backdrop-filter,
none
);
backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
-webkit-user-select: text;
user-select: text;
}
-13
View File
@@ -1061,31 +1061,18 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
position: absolute;
top: 0;
left: 0;
-webkit-animation: sk-bounce 2s infinite ease-in-out;
animation: sk-bounce 2s infinite ease-in-out;
}
.double-bounce2 {
-webkit-animation-delay: -1s;
animation-delay: -1s;
}
@-webkit-keyframes sk-bounce {
0%,
100% {
-webkit-transform: scale(0);
}
50% {
-webkit-transform: scale(1);
}
}
@keyframes sk-bounce {
0%,
100% {
transform: scale(0);
-webkit-transform: scale(0);
}
50% {
transform: scale(1);
-webkit-transform: scale(1);
}
}
-1
View File
@@ -70,7 +70,6 @@ export class HaBadge extends LitElement {
--ha-card-background,
var(--card-background-color, white)
);
-webkit-backdrop-filter: var(--ha-card-backdrop-filter, none);
backdrop-filter: var(--ha-card-backdrop-filter, none);
border-width: var(--ha-card-border-width, 1px);
box-shadow: var(--ha-card-box-shadow, none);
-2
View File
@@ -432,7 +432,6 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
}
}
wa-drawer::part(dialog)::backdrop {
-webkit-backdrop-filter: var(
--ha-bottom-sheet-scrim-backdrop-filter,
var(
--ha-dialog-scrim-backdrop-filter,
@@ -470,7 +469,6 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
var(--card-background-color, var(--ha-color-surface-default))
)
);
-webkit-backdrop-filter: var(
--ha-bottom-sheet-surface-backdrop-filter,
var(--ha-dialog-surface-backdrop-filter, none)
);
-1
View File
@@ -13,7 +13,6 @@ export class HaCard extends LitElement {
--ha-card-background,
var(--card-background-color, white)
);
-webkit-backdrop-filter: var(--ha-card-backdrop-filter, none);
backdrop-filter: var(--ha-card-backdrop-filter, none);
box-shadow: var(--ha-card-box-shadow, none);
box-sizing: border-box;
-2
View File
@@ -323,8 +323,6 @@ export class HaControlSelect extends LitElement {
.option .content span {
display: block;
width: 100%;
-webkit-hyphens: auto;
-moz-hyphens: auto;
hyphens: auto;
}
:host([vertical]) {
-2
View File
@@ -393,7 +393,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
}
wa-dialog::part(dialog) {
-webkit-backdrop-filter: var(
--ha-dialog-surface-backdrop-filter,
none
);
@@ -429,7 +428,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
}
wa-dialog::part(dialog)::backdrop {
-webkit-backdrop-filter: var(
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter, none)
);
-1
View File
@@ -63,7 +63,6 @@ class HaFaded extends LitElement {
}
.faded {
cursor: pointer;
-webkit-mask-image: linear-gradient(
to bottom,
black 25%,
transparent 100%
+1 -3
View File
@@ -53,9 +53,7 @@ export class HaMarkdown extends LitElement {
display: block;
}
ha-markdown-element {
-ms-user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
user-select: text;
}
ha-markdown-element > *:first-child {
margin-top: 0;
+2 -6
View File
@@ -12,7 +12,6 @@ import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { toggleAttribute } from "../common/dom/toggle_attribute";
import { stringCompare } from "../common/string/compare";
import { computeRTL } from "../common/util/compute_rtl";
import { throttle } from "../common/util/throttle";
@@ -296,7 +295,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (changedProps.has("alwaysExpand")) {
toggleAttribute(this, "expanded", this.alwaysExpand);
this.toggleAttribute("expanded", this.alwaysExpand);
}
if (!changedProps.has("hass")) {
return;
@@ -665,9 +664,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
flex-direction: column;
overflow: hidden;
overscroll-behavior: contain;
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
background-color: var(--sidebar-background-color);
width: 100%;
box-sizing: border-box;
@@ -897,7 +894,6 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
}
.menu ha-icon-button {
-webkit-transform: scaleX(var(--scale-direction));
transform: scaleX(var(--scale-direction));
}
-11
View File
@@ -29,7 +29,6 @@ import type { MapBaseLayer } from "../../common/map/base-layer";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityLocation } from "../../common/entity/get_entity_location";
import { ensureMapTilesToken } from "../../data/map_tiles";
import { DecoratedMarker } from "../../common/map/decorated_marker";
import { filterXSS } from "../../common/util/xss";
import {
@@ -328,19 +327,11 @@ export class HaMap extends ReactiveElement {
}
this._loading = true;
try {
// The tiles are proxied by core behind a token, so nothing loads without
// one. A host that provides no connection, or a backend without the
// proxy, leaves the map without tiles rather than failing to set up.
const token = this._connection
? await ensureMapTilesToken(this._connection.connection)
: undefined;
const setup = await setupLeafletMap(map, {
latitude: this._config?.latitude ?? 52.3731339,
longitude: this._config?.longitude ?? 4.8903147,
zoom: this.zoom,
darkMode: this._darkMode,
token,
});
// Setting up fetches a style, so the element can be gone by now.
// `disconnectedCallback` had no map to tear down, and keeping this one
@@ -914,8 +905,6 @@ export class HaMap extends ReactiveElement {
#map.clickable:active,
#map:active {
cursor: grabbing;
cursor: -moz-grabbing;
cursor: -webkit-grabbing;
}
/* Only the raster fallback is inverted for dark mode, the vector style
ships its own dark cartography. */
+1 -2
View File
@@ -15,7 +15,6 @@ import { ifDefined } from "lit/directives/if-defined";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { relativeTime } from "../../common/datetime/relative_time";
import { fireEvent } from "../../common/dom/fire_event";
import { toggleAttribute } from "../../common/dom/toggle_attribute";
import { fullEntitiesContext } from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { LogbookEntry } from "../../data/logbook";
@@ -870,7 +869,7 @@ export class HaAutomationTracer extends LitElement {
this.shadowRoot!.querySelectorAll<HaTimeline>(
"ha-timeline[data-path]"
).forEach((el) => {
toggleAttribute(el, "selected", this.selectedPath === el.dataset.path);
el.toggleAttribute("selected", this.selectedPath === el.dataset.path);
if (!this.allowPick || el.tabIndex === 0) {
return;
}
-152
View File
@@ -1,152 +0,0 @@
import type { Connection } from "home-assistant-js-websocket";
import { waitForMs } from "../common/util/wait";
export const MAP_TILES_PATH = "/api/map_tiles";
// Core rotates every 30 minutes and keeps two tokens live, so one handed out
// now is good for at least 30 more. Refreshing sooner leaves room for a slow
// or missed round trip.
const TOKEN_REFRESH_MS = 20 * 60 * 1000;
// Nothing loads without a token, so the first attempts are awaited - briefly,
// or a backend without the proxy would hold the map hostage. The rest retry in
// the background, for the window after a restart where the WebSocket is up but
// the handler is not registered yet.
const BLOCKING_DELAYS_MS = [0, 400, 1000];
const BACKGROUND_DELAYS_MS = [2000, 5000, 10000, 15000];
let token: string | undefined;
let acquiring: Promise<void> | undefined;
let background: Promise<void> | undefined;
let refreshInterval: ReturnType<typeof setInterval> | undefined;
let watchingConnection = false;
let activeConnection: Connection | undefined;
let refreshing: Promise<void> | undefined;
const listeners = new Set<(token: string) => void>();
const fetchToken = async (connection: Connection): Promise<void> => {
const result = await connection.sendMessagePromise<{ token: string }>({
type: "map_tiles/access_token",
});
if (result.token !== token) {
token = result.token;
listeners.forEach((listener) => listener(token!));
}
};
const attempt = async (connection: Connection, delays: number[]) => {
/* eslint-disable no-await-in-loop -- retries are intentionally sequential */
for (const delay of delays) {
if (token) {
return;
}
if (delay) {
await waitForMs(delay);
}
try {
await fetchToken(connection);
return;
} catch {
// try next delay
}
}
/* eslint-enable no-await-in-loop */
};
export const ensureMapTilesToken = async (
connection: Connection
): Promise<string | undefined> => {
// The demo has no proxy to ask, and its tiles come straight from upstream.
if (__DEMO__) {
return undefined;
}
activeConnection = connection;
// Shared, or a dashboard full of maps asks once per map - and a backend
// without the proxy turns that into every retry, per map.
if (!token) {
acquiring ??= attempt(connection, BLOCKING_DELAYS_MS).finally(() => {
acquiring = undefined;
});
await acquiring;
}
if (!token) {
background ??= attempt(connection, BACKGROUND_DELAYS_MS)
.then(() => scheduleRefresh(connection))
.finally(() => {
background = undefined;
});
return undefined;
}
scheduleRefresh(connection);
return token;
};
const scheduleRefresh = (connection: Connection) => {
if (token && !refreshInterval) {
refreshInterval = setInterval(() => {
fetchToken(connection).catch(() => {
// Keep the current token; the next interval retries.
});
}, TOKEN_REFRESH_MS);
}
if (!watchingConnection) {
watchingConnection = true;
// The interval does not fire while the process is suspended, so the token
// can be stale before it comes round; reconnecting is the reliable signal.
connection.addEventListener("ready", () => {
fetchToken(connection).catch(() => {
// Nothing to do; the interval keeps trying.
});
});
}
};
/**
* Forces a new token, for when the current one is refused. Deduplicated: a
* refused map produces one of these per tile.
*/
export const refreshMapTilesToken = (): Promise<void> => {
if (!activeConnection) {
return Promise.resolve();
}
refreshing ??= fetchToken(activeConnection)
.catch(() => {
// Leave the old token in place; a reconnect or the interval retries.
})
.finally(() => {
refreshing = undefined;
});
return refreshing;
};
/** Leaflet bakes its URL template at layer creation, so it needs telling. */
export const subscribeMapTilesToken = (
listener: (token: string) => void
): (() => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
/**
* MapLibre hands tile URLs to a worker, which has no document to resolve a
* relative URL against, so the result has to be absolute.
*/
export const withMapTilesToken = (url: string): string => {
let parsed: URL;
try {
parsed = new URL(url, location.href);
} catch {
return url;
}
if (token && parsed.pathname.startsWith(`${MAP_TILES_PATH}/`)) {
parsed.searchParams.set("token", token);
}
return parsed.href;
};
-1
View File
@@ -6,7 +6,6 @@ export interface CustomPanelConfig {
trust_external: boolean;
js_url?: string;
module_url?: string;
html_url?: string;
// When true, the panel takes care of the safe-area insets itself (e.g. it
// consumes the `--safe-area-inset-*` variables or draws into the safe area on
// purpose). Home Assistant then skips adding its own safe-area padding around
@@ -35,7 +35,6 @@ export class HaMoreInfoControlSelectContainer extends LitElement {
gap: var(--ha-space-3);
margin: auto;
overflow: auto;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
margin: -2px calc(var(--ha-space-6) * -1);
padding: 2px var(--ha-space-6);
@@ -111,7 +111,6 @@ export class HaMoreInfoStateHeader extends LitElement {
padding: var(--ha-space-1) 0;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
`;
@@ -483,8 +483,6 @@ class LightRgbColorPicker extends LitElement {
input[type="color"] {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
border: none;
outline: none;
display: block;
@@ -191,8 +191,8 @@ class LightColorTempPicker extends LitElement {
--control-slider-thickness: 130px;
--control-slider-border-radius: var(--ha-border-radius-6xl);
--control-slider-color: var(--primary-color);
--control-slider-background: -webkit-linear-gradient(
top,
--control-slider-background: linear-gradient(
to bottom,
var(--gradient)
);
--control-slider-tooltip-font-size: var(--ha-font-size-xl);
@@ -362,7 +362,6 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
border: 1px solid var(--divider-color);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
overflow: hidden;
min-height: 80px;
@@ -18,9 +18,6 @@ export class HuiNotificationItemTemplate extends LitElement {
static styles = css`
.contents {
padding: 16px;
-ms-user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
user-select: text;
}
+39 -77
View File
@@ -2,8 +2,6 @@ import type { Connection } from "home-assistant-js-websocket";
import type { CSSResult } from "lit";
import { fireEvent } from "../common/dom/fire_event";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { loadJS } from "../common/dom/load_resource";
import { webComponentsSupported } from "../common/feature-detect/support-web-components";
import { navigate } from "../common/navigate";
import type { CustomPanelInfo } from "../data/panel_custom";
import { baseEntrypointStyles } from "../resources/styles";
@@ -12,23 +10,6 @@ import { dropRealmCollections } from "../util/custom-panel/drop-realm-collection
import { loadCustomPanel } from "../util/custom-panel/load-custom-panel";
import { setCustomPanelProperties } from "../util/custom-panel/set-custom-panel-properties";
declare global {
interface Window {
loadES5Adapter: () => Promise<unknown>;
}
}
let es5Loaded: Promise<unknown> | undefined;
window.loadES5Adapter = () => {
if (!es5Loaded) {
es5Loaded = loadJS(
`${__STATIC_PATH__}polyfills/custom-elements-es5-adapter.js`
).catch(); // Swallow errors as it raises errors on old browsers.
}
return es5Loaded;
};
let panelEl: HTMLElement | undefined;
let initialized = false;
// Kept so we can clean up after ourselves on pagehide without depending on
@@ -66,68 +47,49 @@ function initialize(
document.head.appendChild(style);
const config = panel.config._panel_custom;
let start: Promise<unknown> = Promise.resolve();
if (!webComponentsSupported) {
start = start.then(() => {
loadJS(`${__STATIC_PATH__}polyfills/webcomponents-bundle.js`);
loadJS(`${__STATIC_PATH__}polyfills/lit-polyfill-support.js`);
});
}
loadCustomPanel(config).then(
() => {
panelEl = createCustomPanelElement(config);
if (__BUILD__ === "legacy") {
start = start.then(() => window.loadES5Adapter());
}
start
.then(() => loadCustomPanel(config))
// If our element is using es5, let it finish loading that and define element
// This avoids elements getting upgraded after being added to the DOM
.then(() => es5Loaded || Promise.resolve())
.then(
() => {
panelEl = createCustomPanelElement(config);
const forwardEvent = (ev) => {
if (window.parent.customPanel) {
fireEvent(window.parent.customPanel, ev.type, ev.detail);
}
};
panelEl!.addEventListener("hass-toggle-menu", forwardEvent);
window.addEventListener("location-changed", (ev: any) => {
if (window.parent.customPanel) {
window.parent.customPanel.navigate(
window.location.pathname,
ev.detail
);
}
});
setProperties({ panel, ...properties });
document.body.appendChild(panelEl!);
},
(err) => {
// eslint-disable-next-line
console.error(err, panel);
let errorScreen;
if (panel.url_path === "hassio") {
import("../layouts/supervisor-error-screen");
errorScreen = document.createElement(
"supervisor-error-screen"
) as any;
} else {
import("../layouts/hass-error-screen");
errorScreen = document.createElement("hass-error-screen") as any;
errorScreen.error = `Unable to load the panel source: ${err}.`;
const forwardEvent = (ev) => {
if (window.parent.customPanel) {
fireEvent(window.parent.customPanel, ev.type, ev.detail);
}
const errorStyle = document.createElement("style");
errorStyle.innerHTML = (baseEntrypointStyles as CSSResult).cssText;
document.body.appendChild(errorStyle);
errorScreen.hass = properties.hass;
document.body.appendChild(errorScreen);
};
panelEl!.addEventListener("hass-toggle-menu", forwardEvent);
window.addEventListener("location-changed", (ev: any) => {
if (window.parent.customPanel) {
window.parent.customPanel.navigate(
window.location.pathname,
ev.detail
);
}
});
setProperties({ panel, ...properties });
document.body.appendChild(panelEl!);
},
(err) => {
// eslint-disable-next-line
console.error(err, panel);
let errorScreen;
if (panel.url_path === "hassio") {
import("../layouts/supervisor-error-screen");
errorScreen = document.createElement("supervisor-error-screen") as any;
} else {
import("../layouts/hass-error-screen");
errorScreen = document.createElement("hass-error-screen") as any;
errorScreen.error = `Unable to load the panel source: ${err}.`;
}
);
const errorStyle = document.createElement("style");
errorStyle.innerHTML = (baseEntrypointStyles as CSSResult).cssText;
document.body.appendChild(errorStyle);
errorScreen.hass = properties.hass;
document.body.appendChild(errorScreen);
}
);
document.body.addEventListener("click", (ev) => {
const href = isNavigationClick(ev);
-27
View File
@@ -117,33 +117,6 @@ const initRouting = () => {
})
);
// Strip the rotating token from the cache key, or every rotation refetches
// every tile. The TileJSON is deliberately not matched: it is the switching
// point for the tile source, so it stays on the network.
registerRoute(
({ url }) =>
/^\/api\/map_tiles\/(vector|raster|fonts|sprites)\//.test(url.pathname),
new CacheFirst({
cacheName: "map-tiles",
plugins: [
{
cacheKeyWillBeUsed: async ({ request }) => {
const url = new URL(request.url);
url.searchParams.delete("token");
return url.href;
},
},
// A stale token gives a 403; caching that would pin the failure.
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 500,
maxAgeSeconds: 60 * 60 * 24 * 7,
purgeOnQuotaError: true,
}),
],
})
);
// Short-circuit camera/image proxy requests with an expired signature or a
// missing/undefined token so they don't hit core and get logged as invalid
// login attempts. Registered before the generic /api route below so it wins.
-4
View File
@@ -7,10 +7,6 @@
script.src = src;
return document.head.appendChild(script);
}
if (!("attachShadow" in Element.prototype)) {
_ls("/static/polyfills/webcomponents-bundle.js", true);
_ls("/static/polyfills/lit-polyfill-support.js", true);
}
// Modern browsers are detected primarily using the user agent string.
// A feature detection which roughly lines up with the modern targets is used
// as a fallback to guard against spoofs. It should be updated periodically.
@@ -884,7 +884,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
padding: 0 16px;
box-sizing: border-box;
overflow-x: scroll;
-ms-overflow-style: none;
scrollbar-width: none;
}
+2 -4
View File
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { listenMediaQuery } from "../common/dom/media_query";
import { toggleAttribute } from "../common/dom/toggle_attribute";
import { computeRTLDirection } from "../common/util/compute_rtl";
import "../components/ha-drawer";
import { narrowViewportContext } from "../data/context";
@@ -141,10 +140,9 @@ export class HomeAssistantMain extends LitElement {
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
toggleAttribute(this, "expanded", this.hass.dockedSidebar === "docked");
this.toggleAttribute("expanded", this.hass.dockedSidebar === "docked");
toggleAttribute(
this,
this.toggleAttribute(
"modal",
this._sidebarNarrow || this._externalSidebar || this.hass.kioskMode
);
-15
View File
@@ -25,29 +25,14 @@ class OnboardingLoading extends LitElement {
border-right: 1.1em solid rgba(3, 169, 244, 0.2);
border-bottom: 1.1em solid rgba(3, 169, 244, 0.2);
border-left: 1.1em solid rgb(3, 168, 244);
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
-webkit-animation: load8 1.4s infinite linear;
animation: load8 1.4s infinite linear;
}
@-webkit-keyframes load8 {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes load8 {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
+1 -3
View File
@@ -151,9 +151,7 @@ class PanelClimate extends LitElement {
haStyle,
css`
:host {
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
hui-view-container {
position: relative;
@@ -151,7 +151,6 @@ export class DialogSupportPackage extends LitElement {
}
table > tbody > tr {
-webkit-transition: background-color 0.25s ease;
transition: background-color 0.25s ease;
}
@@ -434,8 +434,6 @@ class HaScheduleForm extends LitElement {
margin: var(--ha-space-2) 0;
height: 450px;
width: 100%;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
--fc-border-color: var(--divider-color);
--fc-event-border-color: var(--divider-color);
@@ -377,9 +377,7 @@ export class MQTTConfigPanel extends LitElement {
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
user-select: initial;
}
.nav-card {
+1 -3
View File
@@ -344,9 +344,7 @@ export class HaConfigLogs extends LitElement {
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
user-select: initial;
}
.search {
position: sticky;
+1 -3
View File
@@ -163,9 +163,7 @@ class HaPanelDevEvent extends LitElement {
}
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
user-select: initial;
display: block;
height: 100%;
}
+1 -3
View File
@@ -650,9 +650,7 @@ class HaPanelDevState extends LitElement {
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
user-select: initial;
display: block;
padding: var(--ha-space-4);
}
@@ -865,7 +865,6 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
gap: var(--ha-space-4);
padding: 0 var(--ha-space-4);
overflow-x: scroll;
-ms-overflow-style: none;
scrollbar-width: none;
}
+1 -3
View File
@@ -196,9 +196,7 @@ class PanelEnergy extends LitElement {
css`
:host {
--ha-view-sections-column-max-width: 100%;
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
.centered {
width: 100%;
+1 -3
View File
@@ -151,9 +151,7 @@ class PanelLight extends LitElement {
haStyle,
css`
:host {
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
hui-view-container {
position: relative;
@@ -171,7 +171,7 @@ class HuiCoverTiltPositionCardFeature
cardFeatureStyles,
css`
.gradient {
background: -webkit-linear-gradient(left, ${GRADIENT});
background: linear-gradient(to right, ${GRADIENT});
opacity: 0.6;
}
`,
@@ -159,8 +159,8 @@ class HuiLightColorTempCardFeature
cardFeatureStyles,
css`
ha-control-slider {
--control-slider-background: -webkit-linear-gradient(
left,
--control-slider-background: linear-gradient(
to right,
var(--gradient)
);
--control-slider-background-opacity: 1;
@@ -214,7 +214,6 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
ha-card {
background: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: none;
box-shadow: none;
padding: 0;
@@ -332,8 +332,6 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
font-size: var(--brightness-font-size);
opacity: 0;
transition: opacity 0.5s ease-in-out;
-moz-transition: opacity 0.5s ease-in-out;
-webkit-transition: opacity 0.5s ease-in-out;
}
.show_brightness {
@@ -143,7 +143,6 @@ export class HuiToggleGroupCard extends LitElement implements LovelaceCard {
ha-card {
background: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: none;
box-shadow: none;
height: 100%;
@@ -852,7 +852,6 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
right: 0;
bottom: 0;
z-index: var(--dialog-z-index, 8);
-webkit-backdrop-filter: var(
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter)
);
@@ -5,7 +5,6 @@ import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { DOMAINS_INPUT_ROW } from "../../../common/const";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { toggleAttribute } from "../../../common/dom/toggle_attribute";
import { computeDomain } from "../../../common/entity/compute_domain";
import "../../../components/entity/state-badge";
import "../../../components/ha-relative-time";
@@ -134,8 +133,7 @@ export class HuiGenericEntityRow extends LitElement {
protected updated(changedProps: PropertyValues<this>): void {
super.updated(changedProps);
toggleAttribute(
this,
this.toggleAttribute(
"no-secondary",
!this.secondaryText && !this.config?.secondary_info
);
@@ -95,7 +95,7 @@ export class HuiImageElement extends LitElement implements LovelaceElement {
-webkit-touch-callout: none !important;
}
hui-image {
-webkit-user-select: none !important;
user-select: none !important;
pointer-events: none;
}
div:focus {
+1 -4
View File
@@ -1336,9 +1336,7 @@ class HUIRoot extends LitElement {
haStyle,
css`
:host {
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
.header {
background-color: var(--app-header-background-color);
@@ -1351,7 +1349,6 @@ class HUIRoot extends LitElement {
0px
)
);
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
backdrop-filter: var(--app-header-backdrop-filter, none);
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
@@ -151,9 +151,7 @@ class PanelMaintenance extends LitElement {
haStyle,
css`
:host {
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
hui-view-container {
position: relative;
+1 -3
View File
@@ -140,9 +140,7 @@ class HaProfileDashboard extends LitElement {
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
user-select: initial;
}
.container {
@@ -98,9 +98,7 @@ class HaProfileSectionBrowser extends LitElement {
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
user-select: initial;
}
.container {
@@ -72,9 +72,7 @@ class HaProfileSectionLocalization extends LitElement {
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
user-select: initial;
}
.container {
@@ -130,9 +130,7 @@ class HaProfileSectionPreferences extends LitElement {
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
user-select: initial;
}
.container {
@@ -85,9 +85,7 @@ class HaProfileSectionSecurity extends LitElement {
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
user-select: initial;
}
.container {
+1 -3
View File
@@ -249,9 +249,7 @@ class PanelSecurity extends LitElement {
haStyle,
css`
:host {
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
hui-view-container {
position: relative;
@@ -137,7 +137,7 @@ export class HaStateControlInfoCoverTiltPosition extends LitElement {
--control-slider-tooltip-font-size: var(--ha-font-size-xl);
}
.gradient {
background: -webkit-linear-gradient(top, ${GRADIENT});
background: linear-gradient(to bottom, ${GRADIENT});
opacity: 0.6;
}
`;
-9
View File
@@ -43,15 +43,6 @@ declare global {
interface Window {
// Custom panel entry point url
customPanelJS: string;
ShadyCSS: {
nativeCss: boolean;
nativeShadow: boolean;
prepareTemplate(templateElement, elementName, elementExtension);
styleElement(element);
styleSubtree(element, overrideProperties);
styleDocument(overrideProperties);
getComputedStyleValue(element, propertyName);
};
}
// for fire event
@@ -1,8 +1,2 @@
export const createCustomPanelElement = (panelConfig) => {
// Legacy support. Custom panels used to have to define element ha-panel-{name}
const tagName =
"html_url" in panelConfig
? `ha-panel-${panelConfig.name}`
: panelConfig.name;
return document.createElement(tagName);
};
export const createCustomPanelElement = (panelConfig) =>
document.createElement(panelConfig.name);
+3 -13
View File
@@ -1,19 +1,12 @@
import { loadJS, loadModule } from "../../common/dom/load_resource";
import type { CustomPanelConfig } from "../../data/panel_custom";
// Make sure we only import every JS-based panel once (HTML import has this built-in)
// Make sure we only import every JS-based panel once
const JS_CACHE = {};
export const getUrl = (
panelConfig: CustomPanelConfig
): { type: "module" | "html" | "js"; url: string } => {
if (panelConfig.html_url) {
return {
type: "html",
url: panelConfig.html_url,
};
}
): { type: "module" | "js"; url: string } => {
// if both module and JS provided, base url on frontend build
if (panelConfig.module_url && panelConfig.js_url) {
if (__BUILD__ === "modern") {
@@ -53,8 +46,5 @@ export const loadCustomPanel = (
}
return JS_CACHE[panelSource.url];
}
if (panelSource.type === "module") {
return loadModule(panelSource.url);
}
return Promise.reject("No valid url found in panel config.");
return loadModule(panelSource.url);
};
+1 -5
View File
@@ -1,5 +1 @@
export const isTouch =
"ontouchstart" in window ||
navigator.maxTouchPoints > 0 ||
// @ts-ignore
navigator.msMaxTouchPoints > 0;
export const isTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
+38 -138
View File
@@ -18,22 +18,6 @@ const maplibreGL = vi.hoisted(() => vi.fn(() => maplibreLayer));
vi.mock("@maplibre/maplibre-gl-leaflet", () => ({ maplibreGL }));
// The token module is driven directly here, so a stale token and the one that
// replaces it can be played out without a WebSocket.
const tokenListeners = vi.hoisted(() => new Set<(token: string) => void>());
const refreshMapTilesToken = vi.hoisted(() => vi.fn());
vi.mock("../../../src/data/map_tiles", () => ({
MAP_TILES_PATH: "/api/map_tiles",
refreshMapTilesToken,
subscribeMapTilesToken: (listener: (token: string) => void) => {
tokenListeners.add(listener);
return () => tokenListeners.delete(listener);
},
withMapTilesToken: (url: string) => new URL(url, location.href).href,
}));
const emitToken = (token: string) =>
tokenListeners.forEach((listener) => listener(token));
const setRTLTextPlugin = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("maplibre-gl", () => ({ setRTLTextPlugin }));
@@ -45,23 +29,19 @@ const STYLE = {
sprite: [{ id: "basics", url: "/static/map/sprites/basics/sprites" }],
};
const rasterLayer = {
// Leaflet returns the layer from addTo, and the source chains off it.
addTo: vi.fn(() => rasterLayer),
redraw: vi.fn(),
options: {} as Record<string, unknown>,
};
const rasterLayer = { addTo: vi.fn() };
// Kept as a local so tests can flip it: @types/leaflet has it readonly.
const browser = { retina: false };
const leaflet = {
tileLayer: vi.fn(() => rasterLayer),
Browser: browser,
} as unknown as LeafletModuleType;
const TOKEN = "test-token";
// `createVectorLayer` listens on both the MapLibre map and the Leaflet map.
const glHandlers: Record<string, (event?: unknown) => void> = {};
const glHandlers: Record<string, () => void> = {};
const glMap = {
setStyle: vi.fn(),
on: vi.fn((event: string, handler: (event?: unknown) => void) => {
on: vi.fn((event: string, handler: () => void) => {
glHandlers[event] = handler;
}),
};
@@ -81,7 +61,6 @@ const isRaster = () => vi.mocked(leaflet.tileLayer).mock.calls.length === 1;
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
tokenListeners.clear();
maplibreLayer.options = {};
maplibreGL.mockReturnValue(maplibreLayer);
maplibreLayer.addTo.mockImplementation(() => maplibreLayer);
@@ -104,44 +83,40 @@ describe("createBaseLayer", () => {
it("falls back to raster tiles without WebGL2", async () => {
const createBaseLayer = await setWebGL2(false);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
expect(maplibreGL).not.toHaveBeenCalled();
expect(rasterLayer.addTo).toHaveBeenCalledWith(map);
const [url, options = {}] = vi.mocked(leaflet.tileLayer).mock.calls[0];
// Through core's proxy, which is what identifies Home Assistant upstream -
// a browser can set neither a User-Agent nor a Referer.
expect(url).toContain("/api/map_tiles/raster/{z}/{x}/{y}.png");
// Leaflet substitutes options into the template on every tile request, so
// the token can be refreshed without recreating the layer.
expect(url).toContain("token={token}");
expect(options).toMatchObject({ token: TOKEN });
// OSM serves no raster past 19, so the last level is scaled up instead.
expect(options.maxNativeZoom).toBe(19);
expect(options.maxZoom).toBeGreaterThan(19);
// No referrer: the only one a browser can send is its origin, which
// identifies a Nabu Casa installation. The fallback source is chosen so it
// does not need one.
expect(options).not.toHaveProperty("referrerPolicy");
// The vector layer takes its credit from the style's source instead, so the
// raster layer is the only one carrying attribution itself.
// raster layer is the only one carrying attribution itself - and it credits
// both the data and whoever rendered it.
expect(options.attribution).toContain("openstreetmap.org/copyright");
expect(options.attribution).toContain("carto.com/attributions");
expect(url).toMatch(/\{z\}\/\{x\}\/\{y\}/);
});
// The demo ships without a backend, so there is no proxy to fall back to.
it("falls back to upstream raster in the demo, with a referrer", async () => {
vi.stubGlobal("__DEMO__", true);
// The devices on the fallback are the old retina tablets, and the source
// serves @2x, so they get sharp tiles without quadrupling the requests.
it("asks for @2x raster tiles on a retina screen", async () => {
const createBaseLayer = await setWebGL2(false);
await createBaseLayer(leaflet, map, false, undefined);
browser.retina = true;
const [url, options = {}] = vi.mocked(leaflet.tileLayer).mock.calls[0];
expect(url).toBe("https://tile.openstreetmap.org/{z}/{x}/{y}.png");
// OSM refuses a browser that sends neither, and the demo page's meta
// policy strips the referrer unless the tiles ask for it back.
expect(options.referrerPolicy).toBe("origin");
await createBaseLayer(leaflet, map, false);
expect(vi.mocked(leaflet.tileLayer).mock.calls[0][0]).toContain("@2x.png");
browser.retina = false;
});
it("registers the RTL text plugin once, lazily, from our own host", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
await createBaseLayer(leaflet, map, false);
expect(setRTLTextPlugin).toHaveBeenCalledOnce();
expect(setRTLTextPlugin).toHaveBeenCalledWith(
@@ -153,7 +128,7 @@ describe("createBaseLayer", () => {
it("uses vector tiles when WebGL2 is available", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
expect(maplibreGL).toHaveBeenCalledOnce();
expect(maplibreLayer.addTo).toHaveBeenCalledWith(map);
@@ -169,7 +144,7 @@ describe("createBaseLayer", () => {
})
);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
});
@@ -182,7 +157,7 @@ describe("createBaseLayer", () => {
throw new Error("Failed to initialize WebGL");
});
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
expect(maplibreLayer.remove).toHaveBeenCalled();
@@ -197,7 +172,7 @@ describe("createBaseLayer", () => {
throw new Error("nothing to remove");
});
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
});
@@ -210,7 +185,7 @@ describe("setDarkMode", () => {
it("swaps the style, and ignores a repeat of the current mode", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
const baseLayer = await createBaseLayer(leaflet, map, false);
baseLayer.setDarkMode(true);
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
@@ -224,7 +199,7 @@ describe("setDarkMode", () => {
// the tracked mode, and the next toggle to that mode would do nothing.
it("can retry a mode whose request failed while another was in flight", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
const baseLayer = await createBaseLayer(leaflet, map, false);
const failing = vi.fn(async () => {
throw new Error("offline");
@@ -271,7 +246,7 @@ describe("setDarkMode", () => {
// The layer only settles once its first style resolves, so let that one
// through before the map exists to switch.
const pending = createBaseLayer(leaflet, map, false, TOKEN);
const pending = createBaseLayer(leaflet, map, false);
await vi.waitFor(() => expect(resolvers).toHaveLength(1));
resolvers.shift()!(styleResponse("light"));
const baseLayer = await pending;
@@ -303,7 +278,7 @@ describe("WebGL context loss", () => {
it("falls back to raster tiles when the context stays lost", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
expect(leaflet.tileLayer).not.toHaveBeenCalled();
glHandlers.webglcontextlost();
@@ -315,7 +290,7 @@ describe("WebGL context loss", () => {
it("keeps the vector layer when the context comes back", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
glHandlers.webglcontextrestored();
@@ -331,7 +306,7 @@ describe("WebGL context loss", () => {
it("waits for the page to be visible before falling back", async () => {
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
vi.runAllTimers();
@@ -347,7 +322,7 @@ describe("WebGL context loss", () => {
it("keeps the vector layer when a hidden page gets its context back", async () => {
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
glHandlers.webglcontextrestored();
@@ -362,7 +337,7 @@ describe("WebGL context loss", () => {
it("stops listening for visibility once it has fallen back", async () => {
const remove = vi.spyOn(document, "removeEventListener");
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
vi.runAllTimers();
@@ -379,7 +354,7 @@ describe("WebGL context loss", () => {
it("stops answering theme changes once it has fallen back", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
const baseLayer = await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
vi.runAllTimers();
@@ -388,78 +363,3 @@ describe("WebGL context loss", () => {
expect(glMap.setStyle).not.toHaveBeenCalled();
});
});
// A refused request leaves the source dead: the TileJSON is fetched once and
// MapLibre never retries it, so the map stays blank until the style is
// applied again.
describe("recovering from a refused token", () => {
it("asks for a new token and re-applies the style once it arrives", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
glMap.setStyle.mockClear();
glHandlers.error({ error: { status: 403 } });
expect(refreshMapTilesToken).toHaveBeenCalled();
emitToken("fresh-token");
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
});
it("does not keep asking while the proxy refuses for another reason", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
for (let i = 0; i < 5; i++) {
glHandlers.error({ error: { status: 403 } });
}
expect(refreshMapTilesToken).toHaveBeenCalledOnce();
});
// The style itself always loads - it is a local file - so applying one while
// the token is still stale says nothing about whether requests get through.
it("still recovers when the theme changes before the token arrives", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
glMap.setStyle.mockClear();
glHandlers.error({ error: { status: 403 } });
baseLayer.setDarkMode(true);
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
emitToken("fresh-token");
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledTimes(2));
});
it("ignores errors that are not a refusal", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
glHandlers.error({ error: { status: 500 } });
expect(refreshMapTilesToken).not.toHaveBeenCalled();
});
it("leaves a working map alone when the token is merely refreshed", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
glMap.setStyle.mockClear();
emitToken("fresh-token");
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
expect(glMap.setStyle).not.toHaveBeenCalled();
});
it("redraws the raster layer so refused tiles are asked for again", async () => {
const createBaseLayer = await setWebGL2(false);
await createBaseLayer(leaflet, map, false, TOKEN);
emitToken("fresh-token");
expect(rasterLayer.options.token).toBe("fresh-token");
expect(rasterLayer.redraw).toHaveBeenCalled();
});
});
-222
View File
@@ -1,222 +0,0 @@
import type { Connection } from "home-assistant-js-websocket";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// MapLibre hands tile URLs to a worker, which has no document to resolve a
// relative URL against. Measured: without absolute URLs nothing loads at all -
// not the TileJSON, not the glyphs, not a tile - and MapLibre reports no error,
// so this is worth pinning down.
const connectionWith = (...tokens: string[]) => {
let call = 0;
const listeners: Record<string, () => void> = {};
return {
sendMessagePromise: vi.fn(async () => ({
token: tokens[Math.min(call++, tokens.length - 1)],
})),
addEventListener: vi.fn((event: string, cb: () => void) => {
listeners[event] = cb;
}),
listeners,
} as unknown as Connection & { listeners: Record<string, () => void> };
};
// Mirrors BLOCKING_DELAYS_MS, which is not exported.
const BLOCKING_ATTEMPTS = 3;
const load = async () => {
vi.resetModules();
return import("../../src/data/map_tiles");
};
describe("withMapTilesToken", () => {
beforeEach(() => {
vi.resetModules();
});
it("makes relative URLs absolute", async () => {
const { withMapTilesToken } = await load();
expect(withMapTilesToken("/api/map_tiles/tilejson.json")).toBe(
`${location.origin}/api/map_tiles/tilejson.json`
);
});
it("adds the token to proxy URLs once there is one", async () => {
const { ensureMapTilesToken, withMapTilesToken } = await load();
await ensureMapTilesToken(connectionWith("abc123"));
const url = new URL(withMapTilesToken("/api/map_tiles/vector/1/0/0.mvt"));
expect(url.searchParams.get("token")).toBe("abc123");
expect(url.pathname).toBe("/api/map_tiles/vector/1/0/0.mvt");
});
it("leaves anything outside the proxy alone", async () => {
const { ensureMapTilesToken, withMapTilesToken } = await load();
await ensureMapTilesToken(connectionWith("abc123"));
const url = new URL(
withMapTilesToken("https://example.com/tiles/1/0/0.png")
);
expect(url.searchParams.get("token")).toBeNull();
expect(url.href).toBe("https://example.com/tiles/1/0/0.png");
});
it("still returns an absolute URL when there is no token", async () => {
const { withMapTilesToken } = await load();
expect(
withMapTilesToken("/api/map_tiles/fonts/noto_sans_regular/0-255.pbf")
).toBe(
`${location.origin}/api/map_tiles/fonts/noto_sans_regular/0-255.pbf`
);
});
});
describe("ensureMapTilesToken", () => {
beforeEach(() => {
vi.resetModules();
});
it("asks the backend once and reuses the answer", async () => {
const { ensureMapTilesToken } = await load();
const connection = connectionWith("abc123");
expect(await ensureMapTilesToken(connection)).toBe("abc123");
expect(await ensureMapTilesToken(connection)).toBe("abc123");
expect(connection.sendMessagePromise).toHaveBeenCalledTimes(1);
});
// A backend without the proxy must not hold the map hostage while the
// retries run; the map falls back to loading nothing rather than to nothing
// being drawn at all.
it("gives up quickly on a backend that does not answer", async () => {
const { ensureMapTilesToken } = await load();
const connection = {
sendMessagePromise: vi.fn(async () => {
throw new Error("unknown command");
}),
} as unknown as Connection;
const started = Date.now();
expect(await ensureMapTilesToken(connection)).toBeUndefined();
expect(Date.now() - started).toBeLessThan(3000);
});
it("tells subscribers when a token arrives, for Leaflet's baked template", async () => {
const { ensureMapTilesToken, subscribeMapTilesToken } = await load();
const listener = vi.fn();
subscribeMapTilesToken(listener);
await ensureMapTilesToken(connectionWith("abc123"));
expect(listener).toHaveBeenCalledWith("abc123");
});
});
// A token can expire before the refresh interval comes round, so recovery
// hangs on the reconnect and on retrying a refused request.
describe("recovering a stale token", () => {
beforeEach(() => {
vi.resetModules();
});
it("fetches a new token when the connection comes back", async () => {
const { ensureMapTilesToken, subscribeMapTilesToken } = await load();
const connection = connectionWith("first", "second") as Connection & {
listeners: Record<string, () => void>;
};
await ensureMapTilesToken(connection);
const listener = vi.fn();
subscribeMapTilesToken(listener);
connection.listeners.ready();
await vi.waitFor(() => expect(listener).toHaveBeenCalledWith("second"));
});
it("asks once for a refresh however many tiles were refused", async () => {
const { ensureMapTilesToken, refreshMapTilesToken } = await load();
const connection = connectionWith("first", "second");
await ensureMapTilesToken(connection);
expect(connection.sendMessagePromise).toHaveBeenCalledTimes(1);
await Promise.all([
refreshMapTilesToken(),
refreshMapTilesToken(),
refreshMapTilesToken(),
]);
expect(connection.sendMessagePromise).toHaveBeenCalledTimes(2);
});
it("keeps the old token when the refresh fails", async () => {
const { ensureMapTilesToken, refreshMapTilesToken, withMapTilesToken } =
await load();
const connection = connectionWith("first");
await ensureMapTilesToken(connection);
vi.mocked(connection.sendMessagePromise).mockRejectedValueOnce(
new Error("disconnected")
);
await refreshMapTilesToken();
expect(
new URL(
withMapTilesToken("/api/map_tiles/vector/1/0/0.mvt")
).searchParams.get("token")
).toBe("first");
});
});
describe("asking for a token once", () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("shares one attempt across every map on the dashboard", async () => {
const { ensureMapTilesToken } = await load();
const connection = connectionWith("abc123");
await Promise.all([
ensureMapTilesToken(connection),
ensureMapTilesToken(connection),
ensureMapTilesToken(connection),
]);
expect(connection.sendMessagePromise).toHaveBeenCalledTimes(1);
});
// Without sharing, a backend that has no proxy yet gets every retry of every
// map: three blocking and four background attempts each.
it("shares the retries too when the backend does not answer", async () => {
const { ensureMapTilesToken } = await load();
const connection = {
sendMessagePromise: vi.fn(async () => {
throw new Error("unknown command");
}),
addEventListener: vi.fn(),
} as unknown as Connection;
await Promise.all([
ensureMapTilesToken(connection),
ensureMapTilesToken(connection),
ensureMapTilesToken(connection),
]);
expect(connection.sendMessagePromise).toHaveBeenCalledTimes(
BLOCKING_ATTEMPTS
);
});
it("does not ask at all in the demo, which has no proxy", async () => {
vi.stubGlobal("__DEMO__", true);
const { ensureMapTilesToken } = await load();
const connection = connectionWith("abc123");
expect(await ensureMapTilesToken(connection)).toBeUndefined();
expect(connection.sendMessagePromise).not.toHaveBeenCalled();
});
});
-8
View File
@@ -100,7 +100,6 @@ const commandResults: Record<string, unknown> = {
},
"lovelace/info": { resource_mode: "storage" },
"lovelace/resources": [],
"map_tiles/access_token": { token: "map-tiles-token" },
"recorder/info": {
migration_in_progress: false,
migration_is_live: false,
@@ -178,13 +177,6 @@ export async function setupOnboardingMocks(
): Promise<OnboardingCalls> {
const calls: OnboardingCalls = { tokenRequests: [] };
// The location step shows a map, which asks core's tile proxy for tiles core
// is not running here. Answer them so the outcome does not depend on what the
// dev server does with an unknown /api path.
await page.route("**/api/map_tiles/**", (route) =>
route.fulfill({ status: 404, body: "" })
);
await page.route("**/api/onboarding**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
-16
View File
@@ -6909,13 +6909,6 @@ __metadata:
languageName: node
linkType: hard
"@webcomponents/webcomponentsjs@npm:2.8.0":
version: 2.8.0
resolution: "@webcomponents/webcomponentsjs@npm:2.8.0"
checksum: 10/acba7dce2cb60f0505369247ce54eeca3efbd386b98c6cc50d7d318090f551d753b20477038983d7ead9376669b4d702effca21a24c0a1b511d42b1ea53ee9a9
languageName: node
linkType: hard
"@zeit/schemas@npm:2.36.0":
version: 2.36.0
resolution: "@zeit/schemas@npm:2.36.0"
@@ -8648,13 +8641,6 @@ __metadata:
languageName: node
linkType: hard
"dialog-polyfill@npm:0.5.6":
version: 0.5.6
resolution: "dialog-polyfill@npm:0.5.6"
checksum: 10/42428793b04fd2e0a67dfb75838703488d7d05f73663c3251441ad6ed154b8dc71d65ed03d5a0ba4a83c6167c2e6f791cbe1574d0dca37dac1405ce3816033ca
languageName: node
linkType: hard
"didyoumean2@npm:4.1.0":
version: 4.1.0
resolution: "didyoumean2@npm:4.1.0"
@@ -10418,7 +10404,6 @@ __metadata:
"@vitest/coverage-v8": "npm:4.1.11"
"@vvo/tzdb": "npm:6.198.0"
"@webcomponents/scoped-custom-element-registry": "npm:0.0.10"
"@webcomponents/webcomponentsjs": "npm:2.8.0"
babel-loader: "npm:10.1.1"
babel-plugin-polyfill-corejs3: "npm:1.0.0"
barcode-detector: "npm:3.2.2"
@@ -10434,7 +10419,6 @@ __metadata:
deep-clone-simple: "npm:1.1.1"
deep-freeze: "npm:0.0.1"
del: "npm:8.0.1"
dialog-polyfill: "npm:0.5.6"
echarts: "npm:6.1.0"
echarts-extension-chart2music: "npm:0.1.1"
element-internals-polyfill: "npm:3.0.2"