mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-01 06:40:03 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2edbab08ea | ||
|
|
fa30c6b99a | ||
|
|
77eebbe041 | ||
|
|
3ebec556ea | ||
|
|
a4d19bc3b0 | ||
|
|
567f56421b | ||
|
|
a45c725301 |
@@ -75,3 +75,5 @@ test/e2e/app/dist/
|
||||
|
||||
test/benchmarks/results/
|
||||
|
||||
# Downloaded map glyph and sprite archives
|
||||
.map-assets/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}/`)),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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 =
|
||||
'© <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 =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, ' +
|
||||
'© <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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -63,7 +63,6 @@ class HaFaded extends LitElement {
|
||||
}
|
||||
.faded {
|
||||
cursor: pointer;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
black 25%,
|
||||
transparent 100%
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 +1 @@
|
||||
export const isTouch =
|
||||
"ontouchstart" in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
// @ts-ignore
|
||||
navigator.msMaxTouchPoints > 0;
|
||||
export const isTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user