Compare commits

...

37 Commits

Author SHA1 Message Date
Aidan Timson 17316f01cd Format 2026-07-31 11:50:04 +01:00
Aidan Timson a06ae4f984 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-31 11:43:39 +01:00
copilot-swe-agent[bot] 1b3bf954aa Fix syntax error: misplaced brace before finally in confirmStopConflict
Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>
2026-07-31 11:23:35 +01:00
Aidan Timson bfaf7b3daa Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-31 11:23:35 +01:00
Aidan Timson d015b72685 Offer to stop conflicting managed workflows 2026-07-31 11:23:35 +01:00
Aidan Timson b1ccb6355d Wait for settings overview readiness (#53352)
* Wait for settings overview readiness

* Move panel readiness setup to constructor

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Cache bluetooth config entries fetch in settings navigation

* Fix child panel readiness registration race

* Avoid redundant settings navigation renders

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-31 13:22:00 +03:00
Aidan Timson 5f2086765a Add managed translation fetching to dev servers (#53417) 2026-07-31 13:20:50 +03:00
renovate[bot] 02c51a2789 Update dependency @rspack/core to v2.1.6 (#53420)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-31 11:39:06 +02:00
Paul Bottein 706421f3c5 Fix tile name truncated too early (#53416) 2026-07-31 11:52:23 +03:00
Aidan Timson cbc2e34638 Fix profile theme link colour (#53415) 2026-07-31 10:47:45 +02:00
Yosi Levy 0bc0b08978 RTL fixes (#53414) 2026-07-31 08:41:52 +01:00
karwosts 48394db843 Validate dialog-form on submit (#53381)
* Validate dialog-form on submit

* remove whitespace

* put errors on the stack

* Apply suggestion from @MindFreeze

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Apply suggestion from @MindFreeze

* Fix botched suggestion apply in nested submit

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-31 07:14:40 +00:00
Paul Bottein 83dac08059 Use backend slugify for the entity ID format preview (#53394)
* Use backend slugify for the entity ID format preview

* Update src/panels/config/core/ha-config-entity-id-format.ts

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Use top-level slugify websocket command

* Fix formatting

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-31 09:39:47 +03:00
Aidan Timson 3c6b70ded8 Prevent concurrent managed frontend workflows (#53396)
* Shared build and runner for all build,dev,test flows

* Harden managed process lifecycle

* Make build workflows deterministic

* Test build management contracts

* Queue shared generated output work

* Isolate generated inputs for dev servers

* Preserve dev server child failures

* Make generated lock test deterministic

* Keep test navigator configurable

* Block concurrent frontend workflows

* Simplify workflow lock ownership

* Focus workflow locking on managed commands

* Trim workflow lock unit tests

* Consolidate dev server lifecycle handlers
2026-07-31 09:30:49 +03:00
John G. 1e414f31dc Fix typo in Google Home Matter pairing instructions (#53413)
Corrected a typo in the instructions for pairing a Matter device from Google Home with a pairing code.
2026-07-31 04:27:25 +00:00
karwosts d013051f81 Add a few missing logbook csv fields (#53412) 2026-07-31 06:19:05 +02:00
Bram Kragten eb2efbb669 use defaults provided by core for ports in strings, add url to new lo… (#53407)
* use defaults provided by core for ports in strings, add url to new location after restart

* Apply suggestions from code review

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-31 06:08:38 +02:00
Bram Kragten 7e4d745b21 Prevent http confirm dialog from getting closed (#53406) 2026-07-31 06:08:18 +02:00
Petar Petrov d01f7d98a5 Reject non-http URLs from integrations before using them as links (#53379) 2026-07-30 23:26:29 +02:00
renovate[bot] 6df6b556bd Update dependency @codemirror/view to v6.43.7 (#53409) 2026-07-30 22:34:36 +02:00
Marcel van der Veldt f5c6420fbc Search a media player's own library from the media browser (#53402)
* Search a media player's own library from the media browser

The media browser search only ever asked the media sources, so searching
inside a media player's own library (Music Assistant, Sonos, Squeezebox,
Jellyfin) failed. Ask the entity instead when the current item belongs to
it, the same way browsing already does.

* Update src/data/media_source.ts

* Remove blank line in media_source.ts

Removed unnecessary blank line at the top of media_source.ts

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-30 15:44:28 +00:00
karwosts 71ec76185f Fix schedule editor dirty tracking (#53401) 2026-07-30 17:37:18 +02:00
Petar Petrov f08ab2f331 Show which energy power statistic is missing (#53404) 2026-07-30 16:41:28 +02:00
Petar Petrov 77b0b9b5b8 Fix numeric input feature editor showing the wrong default style (#53398) 2026-07-30 16:37:16 +02:00
Aidan Timson 4fcc1adf3a Managed background builds and shared process management (#53374)
* Add managed modern production builds

* Harden managed build process controls

* Update managed process testing guidance

* Harden managed process file operations

* Share generated output process ownership

* Clean stale build state on status

* Address managed build review feedback

Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>

* Use single compress task

* 1

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Forward SIGHUP to foreground processes

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-30 13:25:10 +03:00
renovate[bot] 9699851c26 Update dependency jsdom to v30 (#53395)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-30 12:15:24 +02:00
Raman Gupta a318593c9f Add live updates to Z-Wave JS node config parameters (#53013)
* Fix Z-Wave JS config parameter value type

Type ZWaveJSNodeConfigParam.value as number | null instead of any,
matching what the backend sends. This surfaced two comparison bugs:

- The enumerated picker no-op check compared the stored number against
  the picker's string value, so it never matched and re-selecting the
  current option re-sent the command to the device.
- The numeric input guard coerced null (unknown value) to 0, so
  entering 0 for a parameter with an unknown value was ignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Treat empty numeric input as invalid instead of 0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add live updates to Z-Wave JS node config parameters

Subscribe to config parameter value updates so the page reflects
changes made outside the UI (Z-Wave JS UI, another browser tab, or
the device itself) and so queued changes to sleeping nodes resolve
to a success message when the node wakes and applies them.

- Manage the subscription across connect/disconnect and device
  navigation, deriving deviceId reactively from the route
- Show success results briefly, then clear them automatically
- Clear stale error results when the parameter changes externally
- Discard fetch responses that arrive after navigating to another node

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address review comments

- Use slice() instead of deprecated substr()
- Clear pending result timeouts when navigating to another node
- Handle subscription promise rejections so an older backend without
  the subscribe command degrades gracefully instead of logging
  unhandled rejections

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 12:14:26 +02:00
Aidan Timson 749dd180ae Launch screen tweaks (#53380)
* Polish the launch screen

- Use the system sans-serif font so the launch screen no longer blocks on
  loading Roboto
- Reduce the gap between the logo and the loading text
- Dim the loading text to 66% opacity
- Match the OHF logo variant to the applied theme instead of the system
  color scheme
- Make the launch screen text and buttons unselectable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drop unit test

* Use classMap

* Drop view transition for css transition to avoid flash

---------

Co-authored-by: marcinbauer85 <marcinbauer85@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:13:49 +02:00
Maarten Lakerveld 247780e4c8 Tools > Template use WA Split Panel (#53021)
* Add Web Awesome Split Panel component

* Use SplitPanel for template page. Add vertical/horizontal option. Cleanup, use flexbox.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-30 10:39:41 +02:00
Paul Bottein 7991090313 Allow area card name to wrap to two lines in vertical layout (#53370)
* Allow area card name to wrap to two lines in vertical layout

* Align vertical tile icons in grid sections

* Cut single big word
2026-07-30 10:03:43 +03:00
Aidan Timson 2efa0bd86d Prefetch home dashboard resources concurrently (#53373)
Load Home dashboard resources concurrently
2026-07-30 10:01:13 +03:00
Aidan Timson 0fad4f0097 Remove empty items on narrow data table secondary lines (#53382)
* Skip empty cells on the narrow data table secondary line

In narrow mode the main column renders every other visible column on a
secondary line, but the dot separator was inserted based on the column
index instead of whether the cell rendered anything. A row whose extra
columns are all empty showed a secondary line consisting only of dots,
and an empty column between two filled ones produced a double dot.

Filter empty cells out before joining, and return `nothing` instead of
`html`${nothing}`` for missing timestamps so those cells are detectably
empty too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Extract / clarify data table column visibility

* Fix unbound method and add all-empty secondary line test

Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>

* Fix prettier formatting in ha-data-table test

Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>

---------

Co-authored-by: marcinbauer85 <marcinbauer85@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-30 07:07:43 +02:00
Marcin Bauer 2b90c9a628 Show the automation state on mobile in the automations table (#53375)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:48:51 +02:00
renovate[bot] f3848df19d Update dependency globals to v17.8.0 (#53387)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-29 19:48:26 +02:00
renovate[bot] e2a91c358d Update Node.js to v24.18.1 (#53386)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-29 19:48:22 +02:00
Paul Bottein 8793cb58b9 Show integration logo and name in the replace device dialog (#53384) 2026-07-29 19:24:39 +02:00
Bram Kragten 2442bf662a Strip empty strings from http config (#53377) 2026-07-29 15:33:02 +02:00
96 changed files with 4055 additions and 1145 deletions
+23 -2
View File
@@ -20,6 +20,7 @@ yarn lint # ESLint + Prettier + TypeScript + Lit
yarn format # Auto-fix ESLint + Prettier
yarn lint:types # TypeScript compiler, run without file arguments
yarn test # Vitest
yarn build # Full production build
yarn dev # App dev server
yarn dev:serve # Local serving dev server
```
@@ -28,6 +29,26 @@ Never run `tsc` or `yarn lint:types` with file arguments. File arguments make `t
For focused type feedback on one file, use editor diagnostics instead of a file-scoped `tsc` command.
## Production Builds
Production builds support foreground and managed background execution:
```bash
yarn build # Full foreground build
yarn build --background # Full managed background build
yarn build --modern # Modern frontend_latest bundle only
yarn build --modern --background # Modern managed background build
yarn build --status
yarn build --logs [--follow]
yarn build --stop
```
Use `yarn build --modern --background` for production bundle-size or browser performance comparisons that only need modern browser output. It runs the normal metadata and static preparation, minifies and compresses the modern `frontend_latest` bundle and shared static assets, and generates modern-only entry pages and service workers. It deliberately skips the legacy bundle and its service worker.
Do not pass `--help`, `--background`, or `--modern` to `script/build_frontend`; that raw script does not parse arguments and always starts the full foreground build. Use `yarn build` for managed builds. App builds and development servers keep exclusive ownership of `hass_frontend/` for their lifetime.
Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so only one build or development server can run at a time.
## Unit And Utility Tests
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
@@ -41,7 +62,7 @@ For focused type feedback on one file, use editor diagnostics instead of a file-
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. The default is 8124, or 8123 in a devcontainer.
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal.
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. `yarn dev`, `yarn dev:serve`, `yarn dev:demo`, and `yarn dev:gallery` also support `--fetch-translations`; this runs translation fetching, including first-time GitHub device authentication, under the workflow lock before starting the watcher. It works in foreground and background modes. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal. `yarn dev` and `yarn dev:serve` share one managed process slot because both write the app output.
## Playwright E2E
@@ -59,7 +80,7 @@ The custom development wrappers use `/__ha_dev_status` to identify and manage th
Local runs against a watched development server do not always match CI's clean build artifacts, environment, sharding, or worker configuration. Use background servers for the fast iteration loop, but confirm the relevant CI jobs complete successfully before considering E2E changes verified.
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites in parallel when every managed server is available, otherwise it runs them sequentially to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs suites sequentially when managed servers are unavailable to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
The app suite uses a stripped-down harness for e2e. Demo and gallery use their normal dev servers.
+1 -1
View File
@@ -1 +1 @@
24.18.0
24.18.1
+1 -1
View File
@@ -12,7 +12,7 @@ This is the repository for the official [Home Assistant](https://home-assistant.
- Initial setup: `script/setup`
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
- Production build: `script/build_frontend`
- Production build: `yarn build`
- Gallery: `cd gallery && script/develop_gallery`
## Frontend development
+311
View File
@@ -0,0 +1,311 @@
// Manage a Home Assistant frontend production build with an agent-friendly
// interface, matching build-scripts/dev-server.mjs.
//
// node build-scripts/build-manager.mjs [--modern] [mode]
//
// (no mode) Run in the foreground.
// --background Start detached, print the pid, then exit and leave it
// running.
// --status Report whether a managed build is running.
// --stop Stop a managed build.
// --logs [--follow] Print (or follow) the background build log.
//
// --modern Build only the modern frontend_latest bundle.
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
LIFECYCLE_MODE_FLAGS,
acquireProcessRecord,
isProcessRecordAlive,
outputLog,
offerToStopProcessRecord,
processStartTime,
readProcessRecord,
releaseProcessRecord,
runCli,
spawnDetachedToLog,
spawnForeground,
terminateDetachedProcess,
terminateProcess,
waitFor,
writeProcessRecord,
} from "./managed-process.mjs";
import {
buildCacheDir,
describeOutputOwner,
workflowLockEnv,
workflowLockFile,
} from "./output-lock.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
);
const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp");
const stateDir = path.join(buildCacheDir, "ha-build");
const logFile = path.join(stateDir, "build.log");
const lockFile = workflowLockFile;
const usage = () => {
process.stderr.write(
"Usage: node build-scripts/build-manager.mjs [--modern] " +
"[--background | --status | --stop | --logs [--follow]]\n"
);
};
const parseArgs = (argv) => {
const args = {
mode: "foreground",
modes: [],
follow: false,
modern: false,
unknown: [],
};
for (const arg of argv) {
if (LIFECYCLE_MODE_FLAGS.has(arg)) {
args.mode = LIFECYCLE_MODE_FLAGS.get(arg);
args.modes.push(arg);
} else if (arg === "--modern") {
args.modern = true;
} else if (arg === "--follow") {
args.follow = true;
} else {
args.unknown.push(arg);
}
}
return args;
};
const hints = () =>
" Stop: yarn build --stop\n" +
" Status: yarn build --status\n" +
" Logs: yarn build --logs\n";
const devCommand = (suite) => {
switch (suite) {
case "app-serve":
return "dev:serve";
case "demo":
return "dev:demo";
case "gallery":
return "dev:gallery";
case "e2e-app":
return "test:e2e:app:dev";
default:
return "dev";
}
};
const readBuild = () => readProcessRecord(lockFile);
const releaseBuild = (token) => releaseProcessRecord(lockFile, token);
const stopCommandFor = (owner) =>
owner?.kind === "build"
? "yarn build --stop"
: owner?.kind === "dev"
? `yarn ${devCommand(owner.suite)} --stop`
: undefined;
const acquireBuild = async (modern, foreground) => {
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
const record = {
pid: process.pid,
startTime: processStartTime(process.pid),
processGroup: false,
foreground,
kind: "build",
modern,
starting: true,
token,
};
const result = acquireProcessRecord(lockFile, record);
if (result.acquired) {
return { token };
}
reportExisting(result.existing);
return (await offerToStopProcessRecord({
file: lockFile,
owner: result.existing,
ownerDescription: describeOutputOwner(result.existing),
stopCommand: stopCommandFor(result.existing),
}))
? acquireBuild(modern, foreground)
: { existing: result.existing };
};
const updateBuild = (token, child, processGroup) => {
const existing = readBuild();
if (existing?.token !== token) {
throw Error("Frontend build lock ownership was lost during startup.");
}
writeProcessRecord(lockFile, {
...existing,
pid: child.pid,
startTime: processStartTime(child.pid),
processGroup,
starting: false,
});
};
const taskFor = (modern) => (modern ? "build-app-modern" : "build-app");
const reportExisting = (existing) => {
if (existing?.kind === "output") {
process.stdout.write(
`${describeOutputOwner(existing)} already owns the build and development workflow` +
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n`
);
return;
}
if (existing?.kind === "dev") {
const command = devCommand(existing.suite);
process.stdout.write(
`Dev server (${existing.suite}) already running` +
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n` +
` Stop: yarn ${command} --stop\n` +
` Status: yarn ${command} --status\n` +
` Logs: yarn ${command} --logs\n`
);
return;
}
process.stdout.write(
`Frontend ${existing?.modern ? "modern " : ""}build already running` +
`${existing?.pid ? ` (pid ${existing.pid})` : ""}.\n${hints()}`
);
};
const runForeground = async (modern) => {
const lock = await acquireBuild(modern, true);
if (!lock.token) {
return 1;
}
try {
return await spawnForeground({
cmd: gulpBin,
args: [taskFor(modern)],
cwd: repoRoot,
env: workflowLockEnv(lock.token),
processGroup: true,
onSpawn: (child) => updateBuild(lock.token, child, true),
});
} finally {
releaseBuild(lock.token);
}
};
const runBackground = async (modern) => {
const lock = await acquireBuild(modern, false);
if (!lock.token) {
return 1;
}
let child;
try {
child = await spawnDetachedToLog({
cmd: gulpBin,
args: [taskFor(modern)],
cwd: repoRoot,
env: workflowLockEnv(lock.token),
logFile,
});
updateBuild(lock.token, child, true);
process.stdout.write(
`Started ${modern ? "modern " : ""}frontend build (pid ${child.pid})\n` +
hints()
);
return 0;
} catch (err) {
if (child) {
await terminateDetachedProcess(child);
}
releaseBuild(lock.token);
throw err;
}
};
const runStatus = () => {
const existing = readBuild();
if (existing?.kind === "build" && isProcessRecordAlive(existing)) {
process.stdout.write(
`Frontend ${existing.modern ? "modern " : ""}build running (pid ${existing.pid}).\n`
);
} else {
if (existing?.kind === "build") {
releaseBuild(existing.token);
}
process.stdout.write("Frontend build not running.\n");
}
return 0;
};
const runStop = async () => {
let existing = readBuild();
if (existing?.kind !== "build") {
process.stdout.write("Frontend build not running.\n");
return 0;
}
if (existing?.starting) {
const token = existing.token;
await waitFor(
() => {
const current = readBuild();
return !current?.starting || current.token !== token;
},
100,
5000
);
existing = readBuild();
}
if (
!existing ||
existing.kind !== "build" ||
!isProcessRecordAlive(existing)
) {
if (existing) releaseBuild(existing.token);
process.stdout.write("Frontend build not running.\n");
return 0;
}
if (
!(await terminateProcess({
pid: existing.pid,
processGroup: existing.processGroup,
isStopped: () => !isProcessRecordAlive(existing),
}))
) {
process.stderr.write(
`Failed to stop frontend build (pid ${existing.pid}). Stop it manually.\n`
);
return 1;
}
releaseBuild(existing.token);
process.stdout.write(`Stopped frontend build (pid ${existing.pid}).\n`);
return 0;
};
const runLogs = (follow) =>
outputLog(logFile, follow, `No frontend build log yet (${logFile}).\n`);
const main = async () => {
const args = parseArgs(process.argv.slice(2));
if (args.unknown.length) {
process.stderr.write(`Unknown arguments: ${args.unknown.join(" ")}\n`);
usage();
return 1;
}
if (args.modes.length > 1 || (args.follow && args.mode !== "logs")) {
process.stderr.write("Invalid combination of build arguments.\n");
usage();
return 1;
}
const handlers = {
foreground: () => runForeground(args.modern),
background: () => runBackground(args.modern),
status: runStatus,
stop: runStop,
logs: () => runLogs(args.follow),
};
return handlers[args.mode]();
};
runCli(main);
File diff suppressed because it is too large Load Diff
+27
View File
@@ -1,5 +1,6 @@
import gulp from "gulp";
import env from "../env.cjs";
import { createWorkflowLockTask } from "../output-lock.mjs";
import "./clean.js";
import "./compress.js";
import "./entry-html.js";
@@ -17,6 +18,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-app"),
"clean",
gulp.parallel(
"gen-service-worker-app-dev",
@@ -36,6 +38,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-app"),
"clean",
gulp.parallel(
"gen-icons-json",
@@ -51,6 +54,30 @@ gulp.task(
)
);
gulp.task(
"build-app-modern",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-app-modern"),
"clean",
gulp.parallel(
"gen-icons-json",
"build-translations",
"build-locale-data",
"gen-licenses"
),
"copy-static-app",
"rspack-prod-app-modern",
gulp.parallel(
"gen-pages-app-prod-modern",
"gen-service-worker-app-prod-modern"
),
...(env.isTestBuild() || env.isStatsBuild() ? [] : ["compress-app"])
)
);
gulp.task(
"analyze-app",
gulp.series(
+3
View File
@@ -1,4 +1,5 @@
import gulp from "gulp";
import { createWorkflowLockTask } from "../output-lock.mjs";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
@@ -13,6 +14,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-demo"),
"clean-demo",
"translations-enable-merge-backend",
gulp.parallel(
@@ -32,6 +34,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-demo"),
"clean-demo",
// Cast needs to be backwards compatible and older HA has no translations
"translations-enable-merge-backend",
+3
View File
@@ -1,4 +1,5 @@
import gulp from "gulp";
import { createWorkflowLockTask } from "../output-lock.mjs";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
@@ -12,6 +13,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-e2e-test-app"),
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel(
@@ -31,6 +33,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-e2e-test-app"),
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
+15 -2
View File
@@ -147,9 +147,11 @@ const genPagesProdTask =
{
...commonVars,
latestEntryJS: entries.map((entry) => latestManifest[`${entry}.js`]),
es5EntryJS: entries.map((entry) => es5Manifest[`${entry}.js`]),
es5EntryJS: outputES5
? entries.map((entry) => es5Manifest[`${entry}.js`])
: [],
latestCustomPanelJS: latestManifest["custom-panel.js"],
es5CustomPanelJS: es5Manifest["custom-panel.js"],
es5CustomPanelJS: outputES5 ? es5Manifest["custom-panel.js"] : "",
}
);
minifiedHTML.push(
@@ -184,6 +186,17 @@ gulp.task(
)
);
gulp.task(
"gen-pages-app-prod-modern",
genPagesProdTask(
APP_PAGE_ENTRIES,
paths.root_dir,
paths.app_output_root,
paths.app_output_latest,
undefined
)
);
const CAST_PAGE_ENTRIES = {
"faq.html": ["launcher"],
"index.html": ["launcher"],
+3
View File
@@ -4,6 +4,7 @@ import gulp from "gulp";
import { load as loadYaml } from "js-yaml";
import { marked } from "marked";
import path from "path";
import { createWorkflowLockTask } from "../output-lock.mjs";
import paths from "../paths.cjs";
import "./clean.js";
import "./entry-html.js";
@@ -164,6 +165,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-gallery"),
"clean-gallery",
"translations-enable-merge-backend",
gulp.parallel(
@@ -195,6 +197,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-gallery"),
"clean-gallery",
"translations-enable-merge-backend",
gulp.parallel(
+26 -7
View File
@@ -108,7 +108,7 @@ const runDevServer = async ({
}
};
const doneHandler = (done) => (err, stats) => {
const doneHandler = () => (err, stats) => {
if (err) {
log.error(err.stack || err);
if (err.details) {
@@ -122,18 +122,26 @@ const doneHandler = (done) => (err, stats) => {
}
log(`Build done @ ${new Date().toLocaleTimeString()}`);
if (done) {
done();
}
};
const prodBuild = (conf) =>
new Promise((resolve) => {
new Promise((resolve, reject) => {
rspack(
conf,
// Resolve promise when done. Because we pass a callback, rspack closes itself
doneHandler(resolve)
(err, stats) => {
if (err) {
reject(err);
} else if (stats.hasErrors()) {
reject(Error(stats.toString("errors-only")));
} else {
if (stats.hasWarnings()) {
console.log(stats.toString("minimal"));
}
log(`Build done @ ${new Date().toLocaleTimeString()}`);
resolve();
}
}
);
});
@@ -160,6 +168,17 @@ gulp.task("rspack-prod-app", () =>
)
);
gulp.task("rspack-prod-app-modern", () =>
prodBuild(
createAppConfig({
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
isTestBuild: env.isTestBuild(),
latestBuild: true,
})
)
);
gulp.task("rspack-dev-server-demo", () =>
runDevServer({
compiler: rspack(
+10 -3
View File
@@ -34,9 +34,9 @@ gulp.task("gen-service-worker-app-dev", async () => {
);
});
gulp.task("gen-service-worker-app-prod", () =>
const genServiceWorker = (builds) =>
Promise.all(
Object.entries(SW_MAP).map(async ([outPath, build]) => {
builds.map(async ([outPath, build]) => {
const manifest = JSON.parse(
await readFile(join(outPath, "manifest.json"), "utf-8")
);
@@ -83,5 +83,12 @@ gulp.task("gen-service-worker-app-prod", () =>
await symlink(basename(swDest), swOld);
}
})
)
);
gulp.task("gen-service-worker-app-prod", () =>
genServiceWorker(Object.entries(SW_MAP))
);
gulp.task("gen-service-worker-app-prod-modern", () =>
genServiceWorker([[paths.app_output_latest, "modern"]])
);
-2
View File
@@ -1,5 +1,3 @@
/* eslint-disable max-classes-per-file */
import { deleteAsync } from "del";
import { glob } from "glob";
import gulp from "gulp";
+552
View File
@@ -0,0 +1,552 @@
import { spawn, execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { createInterface } from "node:readline/promises";
export const LIFECYCLE_MODE_FLAGS = new Map([
["--background", "background"],
["--status", "status"],
["--stop", "stop"],
["--logs", "logs"],
]);
const AGENT_PROVIDERS = [
{
id: "opencode",
env: [
"OPENCODE",
"OPENCODE_BIN_PATH",
"OPENCODE_SERVER",
"OPENCODE_APP_INFO",
"OPENCODE_MODES",
],
processes: ["opencode"],
},
{
id: "claude-code",
env: ["CLAUDECODE"],
processes: ["claude"],
},
{
id: "cursor",
env: ["CURSOR_TRACE_ID"],
processes: [],
},
{
id: "github-copilot",
matchesEnv: (env) =>
env.TERM_PROGRAM === "vscode" && env.GIT_PAGER === "cat",
processes: [],
},
{
id: "generic",
env: ["AGENT", "AI_AGENT"],
processes: [],
},
];
const MAX_ANCESTRY_HOPS = 24;
const readProcessParent = (pid) => {
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
const commandEnd = stat.lastIndexOf(")");
const commandStart = stat.indexOf("(");
if (commandStart === -1 || commandEnd === -1) {
return undefined;
}
const parentPid = Number.parseInt(
stat.slice(commandEnd + 2).split(" ")[1] ?? "",
10
);
return Number.isFinite(parentPid)
? {
command: stat.slice(commandStart + 1, commandEnd).toLowerCase(),
parentPid,
}
: undefined;
} catch {
return undefined;
}
};
export const detectCodingAgent = (env = process.env, pid = process.pid) => {
if (env.HA_CODING_AGENT === "0") {
return undefined;
}
const envMatch = AGENT_PROVIDERS.find(
(provider) =>
provider.matchesEnv?.(env) || provider.env?.some((name) => env[name])
);
if (envMatch) {
return envMatch.id;
}
if (env.HA_CODING_AGENT === "1") {
return "unknown";
}
let currentPid = pid;
for (let hop = 0; hop < MAX_ANCESTRY_HOPS; hop++) {
const processInfo = readProcessParent(currentPid);
if (!processInfo) {
return undefined;
}
const processMatch = AGENT_PROVIDERS.find((provider) =>
provider.processes.some((name) => processInfo.command.includes(name))
);
if (processMatch) {
return processMatch.id;
}
if (processInfo.parentPid <= 1) {
return undefined;
}
currentPid = processInfo.parentPid;
}
return undefined;
};
const canPromptForConflict = () =>
Boolean(process.stdin.isTTY && process.stderr.isTTY && !detectCodingAgent());
const formatCommand = (command) =>
!("NO_COLOR" in process.env)
? `\u001b[1;36m${command}\u001b[0m`
: `\`${command}\``;
const confirmStopConflict = async (ownerDescription, stopCommand) => {
const readline = createInterface({
input: process.stdin,
output: process.stderr,
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000);
try {
process.stderr.write(
`${ownerDescription} can be stopped with ${formatCommand(stopCommand)}.\n`
);
const answer = await readline.question("Stop it and continue? [y/N] ", {
signal: controller.signal,
});
return ["y", "yes"].includes(answer.trim().toLowerCase());
} catch (err) {
if (err?.name !== "AbortError") {
throw err;
}
process.stderr.write("\n");
return false;
} finally {
clearTimeout(timeout);
readline.close();
}
};
export const sleep = (ms) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
export const waitFor = async (predicate, intervalMs, timeoutMs) => {
const deadline = Date.now() + timeoutMs;
const poll = async () => {
if (await predicate()) {
return true;
}
if (Date.now() >= deadline) {
return false;
}
await sleep(intervalMs);
return poll();
};
return poll();
};
export const isProcessAlive = (pid) => {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (err) {
// EPERM means the process exists but is owned by someone else.
return err.code === "EPERM";
}
};
export const processStartTime = (pid) => {
if (!isProcessAlive(pid)) {
return undefined;
}
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
} catch {
try {
return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return undefined;
}
}
};
export const isProcessRecordAlive = ({ pid, startTime }) =>
Boolean(startTime) && processStartTime(pid) === startTime;
export const readProcessRecord = (file) => {
try {
const data = JSON.parse(fs.readFileSync(file, "utf8"));
return data && Number.isInteger(data.pid) ? data : undefined;
} catch (err) {
if (err.code === "ENOENT" || err instanceof SyntaxError) {
return undefined;
}
throw err;
}
};
export const writeProcessRecord = (file, data) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
try {
fs.writeFileSync(temporary, JSON.stringify(data));
fs.renameSync(temporary, file);
} finally {
removeFileIfExists(temporary);
}
};
export const removeProcessRecord = (file) => {
removeFileIfExists(file);
};
export const acquireProcessRecord = (file, data) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
for (let attempt = 0; attempt < 2; attempt++) {
try {
const fd = fs.openSync(file, "wx");
try {
fs.writeFileSync(fd, JSON.stringify(data));
} finally {
fs.closeSync(fd);
}
return { acquired: true };
} catch (err) {
if (err.code !== "EEXIST") {
throw err;
}
const existing = readProcessRecord(file);
if (existing && isProcessRecordAlive(existing)) {
return { acquired: false, existing };
}
if (!existing && isRecentFile(file)) {
return { acquired: false };
}
const removed = withExclusiveFileLockSync(`${file}.cleanup`, () => {
const current = readProcessRecord(file);
if (
current &&
(current.token !== existing?.token || isProcessRecordAlive(current))
) {
return false;
}
removeProcessRecord(file);
return true;
});
if (!removed.acquired || !removed.value) {
return { acquired: false, existing: readProcessRecord(file) };
}
}
}
return { acquired: false, existing: readProcessRecord(file) };
};
export const releaseProcessRecord = (file, token, onRelease) => {
if (!token) {
return;
}
withExclusiveFileLockSync(`${file}.cleanup`, () => {
const existing = readProcessRecord(file);
if (!existing || existing.token === token) {
onRelease?.();
if (existing) {
removeProcessRecord(file);
}
}
});
};
const removeFileIfExists = (file) => {
try {
fs.rmSync(file);
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
}
};
const isRecentFile = (file) => {
try {
return Date.now() - fs.statSync(file).mtimeMs < 5000;
} catch (err) {
if (err.code === "ENOENT") {
return false;
}
throw err;
}
};
export const withExclusiveFileLockSync = (file, operation) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
for (let attempt = 0; attempt < 2; attempt++) {
let fd;
try {
fd = fs.openSync(file, "wx");
} catch (err) {
if (err.code !== "EEXIST") {
throw err;
}
const owner = readProcessRecord(file);
if (owner && isProcessRecordAlive(owner)) {
return { acquired: false };
}
if (!owner && isRecentFile(file)) {
return { acquired: false };
}
removeFileIfExists(file);
continue;
}
try {
fs.writeFileSync(
fd,
JSON.stringify({
pid: process.pid,
startTime: processStartTime(process.pid),
})
);
return { acquired: true, value: operation() };
} finally {
try {
fs.closeSync(fd);
} finally {
removeFileIfExists(file);
}
}
}
return { acquired: false };
};
export const spawnForeground = ({
cmd,
args,
cwd,
env,
processGroup = false,
onSpawn,
}) =>
new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd,
detached: processGroup,
env,
stdio: "inherit",
});
let settled = false;
const forwardSigint = () =>
signalProcess(child.pid, "SIGINT", processGroup);
const forwardSigterm = () =>
signalProcess(child.pid, "SIGTERM", processGroup);
const forwardSighup = () =>
signalProcess(child.pid, "SIGHUP", processGroup);
const removeSignalHandlers = () => {
process.off("SIGINT", forwardSigint);
process.off("SIGTERM", forwardSigterm);
process.off("SIGHUP", forwardSighup);
};
if (processGroup) {
process.on("SIGINT", forwardSigint);
process.on("SIGTERM", forwardSigterm);
process.on("SIGHUP", forwardSighup);
}
child.once("spawn", () => {
try {
onSpawn?.(child);
} catch (err) {
settled = true;
signalProcess(child.pid, "SIGTERM", processGroup);
removeSignalHandlers();
reject(err);
}
});
child.once("error", (err) => {
if (!settled) {
settled = true;
removeSignalHandlers();
process.stderr.write(`Failed to start ${cmd}: ${err.message}\n`);
resolve(1);
}
});
child.once("exit", (code) => {
if (!settled) {
settled = true;
removeSignalHandlers();
resolve(code ?? 1);
}
});
});
export const spawnDetachedToLog = ({ cmd, args, cwd, env, logFile }) =>
new Promise((resolve, reject) => {
fs.mkdirSync(path.dirname(logFile), { recursive: true });
const fd = fs.openSync(logFile, "w");
let child;
try {
child = spawn(cmd, args, {
cwd,
detached: true,
env,
stdio: ["ignore", fd, fd],
});
} finally {
fs.closeSync(fd);
}
child.once("spawn", () => {
child.unref();
resolve(child);
});
child.once("error", reject);
});
const signalProcess = (pid, signal, processGroup) => {
if (processGroup) {
try {
process.kill(-pid, signal);
return;
} catch {
// Fall back to the process itself.
}
}
try {
process.kill(pid, signal);
} catch {
// Already gone.
}
};
export const terminateProcess = async ({
pid,
isStopped,
processGroup = true,
graceMs = 10_000,
}) => {
signalProcess(pid, "SIGTERM", processGroup);
if (await waitFor(isStopped, 300, graceMs)) {
return true;
}
signalProcess(pid, "SIGKILL", processGroup);
await sleep(300);
return await isStopped();
};
const sameProcessRecord = (current, expected) =>
Boolean(expected?.token) && current?.token === expected.token;
const stopProcessRecord = async (file, owner) => {
let current = readProcessRecord(file);
if (!current) {
return true;
}
if (!sameProcessRecord(current, owner)) {
return false;
}
if (!isProcessRecordAlive(current)) {
releaseProcessRecord(file, current.token);
return true;
}
const stopped = await terminateProcess({
pid: current.pid,
processGroup: current.processGroup ?? false,
isStopped: () => {
const latest = readProcessRecord(file);
return (
!sameProcessRecord(latest, owner) ||
!latest ||
!isProcessRecordAlive(latest)
);
},
});
current = readProcessRecord(file);
if (current && !sameProcessRecord(current, owner)) {
return false;
}
if (!stopped && current) {
return false;
}
releaseProcessRecord(file, owner.token);
return true;
};
export const offerToStopProcessRecord = async ({
file,
owner,
ownerDescription,
stopCommand,
}) => {
if (
!owner ||
!stopCommand ||
!canPromptForConflict() ||
!(await confirmStopConflict(ownerDescription, stopCommand))
) {
return false;
}
return stopProcessRecord(file, owner);
};
export const terminateDetachedProcess = (child) =>
terminateProcess({
pid: child.pid,
processGroup: true,
isStopped: () => !isProcessAlive(child.pid),
});
export const outputLog = (logFile, follow, missingMessage) => {
if (!fs.existsSync(logFile)) {
process.stdout.write(missingMessage);
return Promise.resolve(0);
}
if (!follow) {
process.stdout.write(fs.readFileSync(logFile, "utf8"));
return Promise.resolve(0);
}
return new Promise((resolve) => {
const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" });
let settled = false;
tail.once("error", () => {
if (!settled) {
settled = true;
process.stdout.write(fs.readFileSync(logFile, "utf8"));
resolve(0);
}
});
tail.once("exit", (code) => {
if (!settled) {
settled = true;
resolve(code ?? 1);
}
});
});
};
export const runCli = (main) => {
main().then(
(code) => {
process.exitCode = code;
},
(err) => {
process.stderr.write(`${err?.stack || err}\n`);
process.exitCode = 1;
}
);
};
+127
View File
@@ -0,0 +1,127 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
acquireProcessRecord,
processStartTime,
readProcessRecord,
releaseProcessRecord,
} from "./managed-process.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
);
export const buildCacheDir =
process.env.HA_BUILD_CACHE_DIR ??
path.join(repoRoot, "node_modules", ".cache");
const WORKFLOW_LOCK_TOKEN_ENV = "HA_WORKFLOW_LOCK_TOKEN";
const signalCleanups = new Set();
const cleanupSignals = ["SIGINT", "SIGTERM", "SIGHUP"];
const handleSignal = (signal) => {
for (const cleanup of signalCleanups) {
cleanup();
}
for (const cleanupSignal of cleanupSignals) {
process.off(cleanupSignal, handleSignal);
}
process.kill(process.pid, signal);
};
const registerSignalCleanup = (cleanup) => {
if (signalCleanups.size === 0) {
for (const signal of cleanupSignals) {
process.on(signal, handleSignal);
}
}
signalCleanups.add(cleanup);
};
const unregisterSignalCleanup = (cleanup) => {
signalCleanups.delete(cleanup);
if (signalCleanups.size === 0) {
for (const signal of cleanupSignals) {
process.off(signal, handleSignal);
}
}
};
export const workflowLockFile = path.join(buildCacheDir, "ha-workflow.lock");
export const workflowLockEnv = (token) => ({
...process.env,
[WORKFLOW_LOCK_TOKEN_ENV]: token,
});
export const describeOutputOwner = (owner) => {
if (owner?.kind === "build") {
return `frontend ${owner.modern ? "modern " : ""}build`;
}
if (owner?.kind === "dev") {
return `dev server (${owner.suite ?? "app"})`;
}
return owner?.target ? `Gulp task ${owner.target}` : "another process";
};
const createLockTask = ({ file, inheritedTokenEnv, kind, label, target }) => {
let exitToken;
const cleanup = () => {
if (!exitToken) {
return;
}
releaseProcessRecord(file, exitToken);
exitToken = undefined;
process.off("exit", cleanup);
unregisterSignalCleanup(cleanup);
};
const acquire = async () => {
const inheritedToken = process.env[inheritedTokenEnv];
if (inheritedToken) {
if (readProcessRecord(file)?.token !== inheritedToken) {
throw Error(
`${label} lock ownership was lost before ${target} started.`
);
}
exitToken = inheritedToken;
process.once("exit", cleanup);
registerSignalCleanup(cleanup);
return;
}
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
const record = {
pid: process.pid,
startTime: processStartTime(process.pid),
processGroup: false,
kind,
target,
token,
};
const result = acquireProcessRecord(file, record);
if (!result.acquired) {
const pid = result.existing?.pid;
throw Error(
`Cannot run ${target}: ${describeOutputOwner(result.existing)} ` +
`already owns ${label}${pid ? ` (pid ${pid})` : ""}.`
);
}
exitToken = token;
process.once("exit", cleanup);
registerSignalCleanup(cleanup);
};
acquire.displayName = `lock-${label}:${target}`;
return acquire;
};
export const createWorkflowLockTask = (target) =>
createLockTask({
file: workflowLockFile,
inheritedTokenEnv: WORKFLOW_LOCK_TOKEN_ENV,
kind: "output",
label: "build and development workflow",
target,
});
+4 -10
View File
@@ -34,16 +34,8 @@
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#03a9f4" />
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
<%= renderTemplate("_social_meta.html.template") %>
<style>
@font-face {
font-family: "Roboto Launch Screen";
font-display: block;
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
@@ -64,12 +56,14 @@
padding: 0;
}
#ha-launch-screen {
font-family: "Roboto Launch Screen", sans-serif;
font-family: ui-sans-serif, system-ui, sans-serif;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
user-select: none;
-webkit-user-select: none;
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
}
#ha-launch-screen.removing {
@@ -104,7 +98,7 @@
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
flex: 1;
padding-top: 48px;
padding-top: 16px;
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
+7 -7
View File
@@ -7,7 +7,7 @@
"name": "home-assistant-frontend",
"version": "1.0.0",
"scripts": {
"build": "script/build_frontend",
"build": "node build-scripts/build-manager.mjs",
"lint:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:prettier": "prettier . --cache --check",
@@ -49,7 +49,7 @@
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.6",
"@codemirror/view": "6.43.7",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.5.2",
@@ -152,7 +152,7 @@
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.0",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.5",
"@rspack/core": "2.1.6",
"@rspack/dev-server": "2.1.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -186,14 +186,14 @@
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.7.0",
"globals": "17.8.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "29.1.1",
"jsdom": "30.0.0",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.2.0",
@@ -224,12 +224,12 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.7.0",
"globals": "17.8.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
"packageManager": "yarn@4.17.1",
"volta": {
"node": "24.18.0"
"node": "24.18.1"
}
}
+37 -3
View File
@@ -61,10 +61,44 @@ fi
echo Core is used from ${coreUrl}
# build the frontend so it connects to the passed core
HASS_URL="$coreUrl" ./script/develop &
HASS_URL="$coreUrl" ./node_modules/.bin/gulp develop-app &
develop_pid=$!
# serve the frontend
./node_modules/.bin/serve -p $frontendPort --single --no-port-switching --config ../script/serve-config.json ./hass_frontend &
serve_pid=$!
# keep the script running while serving
wait
stop_children() {
trap - EXIT INT TERM HUP
kill "$develop_pid" "$serve_pid" 2>/dev/null || true
wait "$develop_pid" 2>/dev/null || true
wait "$serve_pid" 2>/dev/null || true
}
trap stop_children EXIT
trap 'stop_children; exit 130' INT
trap 'stop_children; exit 143' TERM
trap 'stop_children; exit 129' HUP
while kill -0 "$develop_pid" 2>/dev/null && kill -0 "$serve_pid" 2>/dev/null; do
sleep 1
done
develop_status=
serve_status=
if ! kill -0 "$develop_pid" 2>/dev/null; then
if wait "$develop_pid"; then develop_status=0; else develop_status=$?; fi
fi
if ! kill -0 "$serve_pid" 2>/dev/null; then
if wait "$serve_pid"; then serve_status=0; else serve_status=$?; fi
fi
if [ -n "$develop_status" ] && [ "$develop_status" -ne 0 ]; then
status=$develop_status
elif [ -n "$serve_status" ]; then
status=$serve_status
else
status=$develop_status
fi
exit "$status"
@@ -1,15 +0,0 @@
import type { EntityIdPart } from "../../data/entity_id_format";
import { slugify } from "../string/slugify";
export const computeEntityIdFormatExample = (
format: EntityIdPart[],
examples: Record<EntityIdPart, string>
): string => {
const parts = format
.map((item) => examples[item])
.filter(Boolean)
.map((part) => slugify(part, "_"))
.filter(Boolean);
return parts.join("_") || "unknown";
};
+52
View File
@@ -0,0 +1,52 @@
import { sanitizeNavigationPath } from "./sanitize-navigation-path";
const HOME_ASSISTANT_SCHEME = "homeassistant://";
/**
* Returns the URL if it is safe to use as a link target, `undefined` otherwise.
* Only absolute `http:` and `https:` URLs pass, the same check
* `ha-attribute-value` already applies to attribute links.
*
* Use for every URL that reaches the frontend as data from an integration
* manifest, an add-on, a config flow or an entity attribute so that a
* `javascript:` URI can never become a clickable link.
*/
export const sanitizeHttpUrl = (
url: string | null | undefined
): string | undefined => {
if (!url) {
return undefined;
}
try {
const { protocol } = new URL(url);
return protocol === "http:" || protocol === "https:" ? url : undefined;
} catch (_err) {
return undefined;
}
};
/** Whether the URL is a `homeassistant://` deep link into the frontend. */
export const isHomeAssistantUrl = (url: string | null | undefined): boolean =>
!!url?.startsWith(HOME_ASSISTANT_SCHEME);
/**
* Turns a `homeassistant://` deep link into an in-app path, or returns
* `undefined` when it does not point inside the frontend. Rewriting the scheme
* on its own is not enough: `homeassistant:///example.com` would become
* `//example.com`, which resolves to another origin.
*/
export const homeAssistantUrlToPath = (
url: string | null | undefined
): string | undefined =>
isHomeAssistantUrl(url)
? sanitizeNavigationPath(`/${url!.slice(HOME_ASSISTANT_SCHEME.length)}`)
: undefined;
/**
* Sanitizes a URL that may be either an external link or a `homeassistant://`
* deep link, returning something safe to bind to an `href`.
*/
export const sanitizeLinkUrl = (
url: string | null | undefined
): string | undefined =>
isHomeAssistantUrl(url) ? homeAssistantUrlToPath(url) : sanitizeHttpUrl(url);
+39 -33
View File
@@ -12,6 +12,7 @@ import {
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { join } from "lit/directives/join";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
@@ -483,13 +484,7 @@ export class HaDataTable extends LitElement {
: ""
}
${Object.entries(columns).map(([key, column]) => {
if (
column.hidden ||
(this.columnOrder && this.columnOrder.includes(key)
? (this.hiddenColumns?.includes(key) ??
column.defaultHidden)
: column.defaultHidden)
) {
if (!this._isColumnVisible(key, column)) {
return nothing;
}
const sorted = key === this.sortColumn;
@@ -657,10 +652,7 @@ export class HaDataTable extends LitElement {
${Object.entries(columns).map(([key, column]) => {
if (
(narrow && !column.main && !column.showNarrow) ||
column.hidden ||
(this.columnOrder && this.columnOrder.includes(key)
? (this.hiddenColumns?.includes(key) ?? column.defaultHidden)
: column.defaultHidden)
!this._isColumnVisible(key, column)
) {
return nothing;
}
@@ -692,28 +684,19 @@ export class HaDataTable extends LitElement {
: narrow && column.main
? html`<div class="primary">${row[key]}</div>
<div class="secondary">
${Object.entries(columns)
.filter(
([key2, column2]) =>
!column2.hidden &&
!column2.main &&
!column2.showNarrow &&
!(this.columnOrder &&
this.columnOrder.includes(key2)
? (this.hiddenColumns?.includes(key2) ??
column2.defaultHidden)
: column2.defaultHidden)
)
.map(
([key2, column2], i) =>
html`${
i !== 0 ? STRINGS_SEPARATOR_DOT : nothing
}${
column2.template
? column2.template(row)
: row[key2]
}`
)}
${join(
Object.entries(columns)
.filter(([key2, column2]) =>
this._isSecondaryColumnVisible(key2, column2)
)
.map(([key2, column2]) =>
column2.template
? column2.template(row)
: row[key2]
)
.filter(this._hasCellValue),
STRINGS_SEPARATOR_DOT
)}
</div>
${
column.extraTemplate
@@ -733,6 +716,29 @@ export class HaDataTable extends LitElement {
`;
};
private _isColumnVisible(key: string, column: DataTableColumnData): boolean {
if (column.hidden) {
return false;
}
if (!this.columnOrder?.includes(key)) {
return !column.defaultHidden;
}
return !(this.hiddenColumns?.includes(key) ?? column.defaultHidden);
}
private _isSecondaryColumnVisible(
key: string,
column: DataTableColumnData
): boolean {
if (column.main || column.showNarrow) {
return false;
}
return this._isColumnVisible(key, column);
}
private _hasCellValue = (value: unknown): boolean =>
value !== undefined && value !== null && value !== "" && value !== nothing;
private async _sortFilterData() {
const startTime = new Date().getTime();
const timeBetweenUpdate = startTime - this._lastUpdate;
+69 -21
View File
@@ -6,8 +6,11 @@ import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import { domainToName } from "../../data/integration";
import type { HomeAssistant } from "../../types";
import type { HassDialog } from "../../dialogs/make-dialog-manager";
import { brandsUrl } from "../../util/brands-url";
import "../ha-dialog";
import "../ha-svg-icon";
import "../item/ha-list-item-button";
@@ -23,11 +26,21 @@ export class DialogDeviceReplaced
@state() private _open = false;
@state() private _configEntryLookup?: Record<string, ConfigEntry>;
@property({ attribute: false }) public hass!: HomeAssistant;
public async showDialog(params: DeviceReplacedDialogParams): Promise<void> {
this._params = params;
this._open = true;
this._loadConfigEntries();
}
private async _loadConfigEntries(): Promise<void> {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
}
public closeDialog(): boolean {
@@ -55,15 +68,23 @@ export class DialogDeviceReplaced
candidates: string[],
primaryId: string | null,
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"]
areas: HomeAssistant["areas"],
configEntryLookup: Record<string, ConfigEntry> | undefined
) =>
candidates.map((deviceId) => {
const device = devices[deviceId];
const area = device ? getDeviceArea(device, areas) : undefined;
const configEntry = device?.primary_config_entry
? configEntryLookup?.[device.primary_config_entry]
: undefined;
return {
deviceId,
name: device ? computeDeviceName(device) : deviceId,
secondary: area ? computeAreaName(area) : undefined,
area: area ? computeAreaName(area) : undefined,
domain: configEntry?.domain,
domainName: configEntry
? domainToName(this.hass.localize, configEntry.domain)
: undefined,
isPrimary: deviceId === primaryId,
};
})
@@ -92,31 +113,54 @@ export class DialogDeviceReplaced
this._params.candidates,
this._params.primaryId,
this.hass.devices,
this.hass.areas
).map(
(item) => html`
this.hass.areas,
this._configEntryLookup
).map((item) => {
const supportingText = [
item.area,
item.domainName,
item.isPrimary
? this.hass.localize(
"ui.components.device-picker.replaced_dialog.recommended"
)
: undefined,
]
.filter(Boolean)
.join(" • ");
return html`
<ha-list-item-button .deviceId=${item.deviceId}>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
${
item.domain
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: html`<ha-svg-icon
slot="start"
.path=${mdiDevices}
></ha-svg-icon>`
}
<span slot="headline">${item.name}</span>
${
item.secondary || item.isPrimary
? html`<span slot="supporting-text">
${[
item.secondary,
item.isPrimary
? this.hass.localize(
"ui.components.device-picker.replaced_dialog.recommended"
)
: undefined,
]
.filter(Boolean)
.join(" • ")}
</span>`
supportingText
? html`<span slot="supporting-text"
>${supportingText}</span
>`
: nothing
}
</ha-list-item-button>
`
)}
`;
})}
</ha-list-base>
</ha-dialog>
`;
@@ -132,6 +176,10 @@ export class DialogDeviceReplaced
padding: 0 var(--ha-space-6) var(--ha-space-4);
color: var(--secondary-text-color);
}
img[slot="start"] {
width: 24px;
height: 24px;
}
`;
}
@@ -85,6 +85,10 @@ export class HaStatisticPicker extends LitElement {
@property() public helper?: string;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean }) public invalid = false;
@property() public placeholder?: string;
@property({ attribute: "statistic-types" })
@@ -526,6 +530,9 @@ export class HaStatisticPicker extends LitElement {
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
.disabled=${this.disabled}
.required=${this.required}
.invalid=${this.invalid}
.errorMessage=${this.errorMessage}
.label=${this.label}
use-top-label
.placeholder=${placeholder}
+10 -2
View File
@@ -57,7 +57,10 @@ import "./ha-code-editor-completion-items";
import type { CompletionItem } from "./ha-code-editor-completion-items";
import "./ha-icon";
import "./ha-icon-button-toolbar";
import type { HaIconButtonToolbar } from "./ha-icon-button-toolbar";
import type {
HaIconButtonToolbar,
HaIconButtonToolbarItem,
} from "./ha-icon-button-toolbar";
declare global {
interface HASSDomEvents {
@@ -115,6 +118,9 @@ export class HaCodeEditor extends ReactiveElement {
@property({ type: Boolean, attribute: "has-test" })
public hasTest = false;
@property({ attribute: false })
public toolbarItems?: (HaIconButtonToolbarItem | string)[];
@property({ attribute: false }) public testing = false;
@property({ type: String }) public placeholder?: string;
@@ -351,7 +357,8 @@ export class HaCodeEditor extends ReactiveElement {
changedProps.has("_canCopy") ||
changedProps.has("_canUndo") ||
changedProps.has("_canRedo") ||
changedProps.has("testing")
changedProps.has("testing") ||
changedProps.has("toolbarItems")
) {
this._updateToolbarButtons();
}
@@ -529,6 +536,7 @@ export class HaCodeEditor extends ReactiveElement {
}
this._editorToolbar.items = [
...(this.toolbarItems ?? []),
...(this.hasTest && !this._isFullscreen
? [
{
+17 -3
View File
@@ -304,14 +304,21 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _renderHelper() {
const showError = this.invalid && this.errorMessage;
const showHelper = !showError && this.helper;
if (!showError && !showHelper) {
if (!showError && !this.helper) {
return nothing;
}
return html`<ha-input-helper-text .disabled=${this.disabled}>
${showError ? this.errorMessage : this.helper}
${
showError
? html`<span class="error">${this.errorMessage}</span> ${
this.helper
? html`<span class="helper">${this.helper}</span>`
: nothing
}`
: this.helper
}
</ha-input-helper-text>`;
}
@@ -448,6 +455,13 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
:host([invalid]) ha-input-helper-text {
color: var(--mdc-theme-error, var(--error-color, #b00020));
}
ha-input-helper-text .error,
ha-input-helper-text .helper {
display: block;
}
ha-input-helper-text .helper {
color: var(--secondary-text-color);
}
wa-popover {
--wa-space-l: 0;
+7 -9
View File
@@ -488,6 +488,11 @@ export class HaServiceControl extends LitElement {
)) ||
serviceData?.description;
const documentationLink =
this._manifest?.is_built_in && this._value?.action
? documentationUrl(this.hass, `/actions/${this._value.action}`)
: this._manifest?.documentation;
const targetSelector =
serviceData && "target" in serviceData
? this._targetSelector(
@@ -514,16 +519,9 @@ export class HaServiceControl extends LitElement {
<div class="description">
${description ? html`<p>${description}</p>` : ""}
${
this._manifest
documentationLink
? html` <a
href=${
this._manifest.is_built_in && this._value?.action
? documentationUrl(
this.hass,
`/actions/${this._value.action}`
)
: this._manifest.documentation
}
href=${documentationLink}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
+68
View File
@@ -0,0 +1,68 @@
import SplitPanel from "@home-assistant/webawesome/dist/components/split-panel/split-panel";
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-split-panel")
export class HaSplitPanel extends SplitPanel {
static get styles(): CSSResultGroup {
return [
SplitPanel.styles,
css`
:host {
--divider-width: var(--ha-split-panel-divider-width, 2px);
--divider-hit-area: var(--ha-split-panel-divider-hit-area, 12px);
--min: var(--ha-split-panel-min, 0);
--max: var(--ha-split-panel-max, 100%);
}
.divider {
background-color: var(--divider-color);
transition: background-color var(--ha-animation-duration-fast)
ease-out;
}
/* Grip affordance so the divider reads as draggable. The divider
already centers its children via flexbox, so keep this in flow.
Consumers slotting their own divider handle can hide it with
--ha-split-panel-grip-display: none. */
.divider::before {
content: "";
width: 2px;
height: var(--ha-space-8);
display: var(--ha-split-panel-grip-display, block);
border-radius: var(--ha-border-radius-pill, 9999px);
background-color: var(--secondary-text-color);
opacity: 0.5;
transition: opacity var(--ha-animation-duration-fast) ease-out;
}
/* In vertical orientation the divider is horizontal, so the grip pill
lies flat instead of standing upright. */
:host([orientation="vertical"]) .divider::before {
width: var(--ha-space-8);
height: 2px;
}
@media (hover: hover) {
:host(:not([disabled])) .divider:hover {
background-color: var(--primary-color);
}
:host(:not([disabled])) .divider:hover::before {
opacity: 1;
}
}
:host(:not([disabled])) .divider:focus-visible {
background-color: var(--primary-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-split-panel": HaSplitPanel;
}
}
+3
View File
@@ -228,6 +228,9 @@ export class HaThemeSettings extends LitElement {
}
static styles = css`
a {
color: var(--primary-color);
}
.inputs {
display: flex;
flex-wrap: wrap;
@@ -34,10 +34,12 @@ import {
browseMediaPlayer,
BROWSER_PLAYER,
MediaClassBrowserSettings,
searchMediaPlayer,
} from "../../data/media-player";
import {
browseLocalMediaPlayer,
isManualMediaSourceContentId,
isMediaSourceContentId,
MANUAL_MEDIA_SOURCE_PREFIX,
searchMedia,
} from "../../data/media_source";
@@ -869,13 +871,31 @@ export class HaMediaPlayerBrowse extends LitElement {
const mediaFilterClasses = this._mediaClassFilter.length
? this._mediaClassFilter
: undefined;
// A player's tree can embed media sources, which resolve their own searches;
// everything else in it uses integration specific ids only the entity knows.
const searchEntityId =
this.entityId &&
this.entityId !== BROWSER_PLAYER &&
!isMediaSourceContentId(navigateId.media_content_id ?? "")
? this.entityId
: undefined;
try {
const { result } = await searchMedia(
this.hass,
navigateId.media_content_id,
searchQuery,
mediaFilterClasses
);
const { result } = searchEntityId
? await searchMediaPlayer(
this.hass,
searchEntityId,
searchQuery,
navigateId.media_content_id,
navigateId.media_content_type,
mediaFilterClasses
)
: await searchMedia(
this.hass,
navigateId.media_content_id,
searchQuery,
mediaFilterClasses
);
// Ignore the response if a newer search started or we navigated away
if (requestId !== this._searchRequestId) {
return;
+17 -2
View File
@@ -15,6 +15,10 @@ export class HaTileContainer extends LitElement {
@property({ type: Boolean })
public vertical = false;
/* reserve a consistent height for the info block instead of sizing to content, so sibling tiles stay aligned */
@property({ type: Boolean, attribute: "fixed-info-height" })
public fixedInfoHeight = false;
@property({ attribute: false })
public interactive = false;
@@ -34,7 +38,10 @@ export class HaTileContainer extends LitElement {
protected render() {
const containerOrientationClass =
this.featurePosition === "inline" ? "horizontal" : "";
const contentClasses = { vertical: this.vertical };
const contentClasses = {
vertical: this.vertical,
"fixed-info-height": this.fixedInfoHeight,
};
return html`
<div
@@ -112,7 +119,15 @@ export class HaTileContainer extends LitElement {
flex-direction: column;
text-align: center;
justify-content: center;
padding: 10px;
padding: 10px var(--ha-space-2);
}
.vertical.fixed-info-height {
/* pin sizing so every tile in a grid reserves the same height, wrapping or not, secondary or not */
gap: 2px;
--ha-tile-info-gap: 2px;
--ha-tile-info-primary-line-height: var(--ha-space-4);
--ha-tile-info-primary-min-height: var(--ha-space-8);
--ha-tile-info-min-height: var(--ha-space-12);
}
.vertical ::slotted([slot="info"]) {
width: 100%;
+25 -4
View File
@@ -15,6 +15,11 @@ import { customElement, property } from "lit/decorators";
*
* @property {boolean} secondaryLoading - Whether the secondary text is loading. Shows a skeleton placeholder.
*
* @csspart primary - The primary text. Style it to opt into another truncation, such as a multi line clamp.
*
* @cssprop --ha-tile-info-gap - The vertical gap between the primary and secondary text. defaults to `0`.
* @cssprop --ha-tile-info-min-height - Minimum height of the primary/secondary block. Set this to reserve space for a missing secondary so it doesn't shift surrounding content. defaults to `auto`.
* @cssprop --ha-tile-info-primary-min-height - Minimum height of the primary text block, independent of the number of rendered lines. Lets tiles that never wrap still match the height of tiles that do. defaults to `auto` (sizes to the actual rendered lines).
* @cssprop --ha-tile-info-primary-font-size - The font size of the primary text. defaults to `var(--ha-font-size-m)`.
* @cssprop --ha-tile-info-primary-font-weight - The font weight of the primary text. defaults to `var(--ha-font-weight-medium)`.
* @cssprop --ha-tile-info-primary-line-height - The line height of the primary text. defaults to `var(--ha-line-height-normal)`.
@@ -39,7 +44,7 @@ export class HaTileInfo extends LitElement {
return html`
<div class="info">
<slot name="primary" class="primary">
<span>${this.primary}</span>
<span part="primary">${this.primary}</span>
</slot>
${
this.secondaryLoading
@@ -59,6 +64,8 @@ export class HaTileInfo extends LitElement {
display: block;
width: 100%;
min-width: 0;
--tile-info-gap: var(--ha-tile-info-gap, 0);
--tile-info-min-height: var(--ha-tile-info-min-height, auto);
--tile-info-primary-font-size: var(
--ha-tile-info-primary-font-size,
var(--ha-font-size-m)
@@ -71,6 +78,10 @@ export class HaTileInfo extends LitElement {
--ha-tile-info-primary-line-height,
var(--ha-line-height-normal)
);
--tile-info-primary-min-height: var(
--ha-tile-info-primary-min-height,
auto
);
--tile-info-primary-letter-spacing: var(
--ha-tile-info-primary-letter-spacing,
0.1px
@@ -106,28 +117,38 @@ export class HaTileInfo extends LitElement {
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: var(--tile-info-gap);
min-height: var(--tile-info-min-height);
}
span,
::slotted(*) {
.primary span,
::slotted([slot="primary"]),
.secondary span,
::slotted([slot="secondary"]) {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 100%;
}
.primary {
display: flex;
align-items: center;
width: 100%;
font-size: var(--tile-info-primary-font-size);
font-weight: var(--tile-info-primary-font-weight);
line-height: var(--tile-info-primary-line-height);
letter-spacing: var(--tile-info-primary-letter-spacing);
color: var(--tile-info-primary-color);
min-height: var(--tile-info-primary-min-height);
}
.secondary {
display: flex;
align-items: center;
width: 100%;
font-size: var(--tile-info-secondary-font-size);
font-weight: var(--tile-info-secondary-font-weight);
line-height: var(--tile-info-secondary-line-height);
letter-spacing: var(--tile-info-secondary-letter-spacing);
color: var(--tile-info-secondary-color);
width: 100%;
}
.placeholder {
width: 140px;
+27 -5
View File
@@ -1,6 +1,7 @@
import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { LocalizeFunc } from "../common/translations/localize";
import { sanitizeHttpUrl } from "../common/url/sanitize-http-url";
import { debounce } from "../common/util/debounce";
import type { HomeAssistant } from "../types";
@@ -28,7 +29,7 @@ export interface IntegrationManifest {
domain: string;
name: string;
config_flow: boolean;
documentation: string;
documentation?: string;
issue_tracker?: string;
dependencies?: string[];
after_dependencies?: string[];
@@ -78,11 +79,27 @@ export enum LogSeverity {
export type IntegrationLogPersistance = "none" | "once" | "permanent";
/**
* A custom integration supplies its own manifest, so its URLs are untrusted
* input. Strip them here, where manifests enter the frontend, so no consumer can
* turn one into a link that runs script.
*/
const sanitizeManifest = <T extends IntegrationManifest | undefined>(
manifest: T
): T =>
manifest
? ({
...manifest,
documentation: sanitizeHttpUrl(manifest.documentation),
issue_tracker: sanitizeHttpUrl(manifest.issue_tracker),
} as T)
: manifest;
export const integrationIssuesUrl = (
domain: string,
manifest: IntegrationManifest
) =>
manifest.issue_tracker ||
sanitizeHttpUrl(manifest.issue_tracker) ||
`https://github.com/home-assistant/core/issues?q=is%3Aissue+is%3Aopen+label%3A%22integration%3A+${domain}%22`;
export const domainToName = (
@@ -101,7 +118,9 @@ export const fetchIntegrationManifests = (
if (integrations) {
params.integrations = integrations;
}
return hass.callWS<IntegrationManifest[]>(params);
return hass
.callWS<IntegrationManifest[]>(params)
.then((manifests) => manifests.map(sanitizeManifest));
};
export const fetchIntegrationManifestsCollection = async (
@@ -113,7 +132,7 @@ export const fetchIntegrationManifestsCollection = async (
});
const manifests: DomainManifestLookup = {};
for (const manifest of fetched) {
manifests[manifest.domain] = manifest;
manifests[manifest.domain] = sanitizeManifest(manifest);
}
setValue(manifests);
// One-time fetch — nothing to unsubscribe from
@@ -125,7 +144,10 @@ export const fetchIntegrationManifestsCollection = async (
export const fetchIntegrationManifest = (
hass: HomeAssistant,
integration: string
) => hass.callWS<IntegrationManifest>({ type: "manifest/get", integration });
) =>
hass
.callWS<IntegrationManifest>({ type: "manifest/get", integration })
.then(sanitizeManifest);
export const fetchIntegrationSetups = (hass: HomeAssistant) =>
hass.callWS<IntegrationSetup[]>({ type: "integration/setup_info" });
+23
View File
@@ -208,6 +208,29 @@ export const browseMediaPlayer = (
media_content_type: mediaContentType,
});
export interface SearchMediaResult {
result: MediaPlayerItem[];
}
export const searchMediaPlayer = (
hass: HomeAssistant,
entityId: string,
searchQuery: string,
mediaContentId?: string,
mediaContentType?: string,
mediaFilterClasses?: string[]
): Promise<SearchMediaResult> =>
hass.callWS<SearchMediaResult>({
type: "media_player/search_media",
entity_id: entityId,
search_query: searchQuery,
// the backend requires these two to be passed together, and JSON
// serialization drops them both when the current item is the root
media_content_id: mediaContentId,
media_content_type: mediaContentType,
media_filter_classes: mediaFilterClasses,
});
export const getCurrentProgress = (stateObj: MediaPlayerEntity): number => {
let progress = stateObj.attributes.media_position!;
+1 -5
View File
@@ -1,5 +1,5 @@
import type { HomeAssistant } from "../types";
import type { MediaPlayerItem } from "./media-player";
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
export interface ResolvedMediaSource {
url: string;
@@ -24,10 +24,6 @@ export const browseLocalMediaPlayer = (
media_content_id: mediaContentId,
});
export interface SearchMediaResult {
result: MediaPlayerItem[];
}
export const searchMedia = (
hass: HomeAssistant,
mediaContentId: string | undefined,
+10
View File
@@ -0,0 +1,10 @@
import type { HomeAssistantApi } from "../types";
export const fetchSlug = (
api: HomeAssistantApi,
text: string
): Promise<{ slug: string }> =>
api.callWS<{ slug: string }>({
type: "slugify",
text,
});
+15
View File
@@ -314,6 +314,11 @@ export interface ZWaveJSSetConfigParamResult {
error?: string;
}
export interface ZwaveJSNodeConfigParameterUpdate {
id: string;
value: number | null;
}
export interface ZWaveJSDataCollectionStatus {
enabled: boolean;
opted_in: boolean;
@@ -732,6 +737,16 @@ export const fetchZwaveNodeConfigParameters = (
device_id,
});
export const subscribeZwaveNodeConfigParameterUpdates = (
hass: HomeAssistant,
device_id: string,
callback: (update: ZwaveJSNodeConfigParameterUpdate) => void
): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage(callback, {
type: "zwave_js/subscribe_config_parameter_updates",
device_id,
});
export const setZwaveNodeConfigParameter = (
hass: HomeAssistant,
device_id: string,
@@ -7,6 +7,7 @@ import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { sanitizeHttpUrl } from "../../common/url/sanitize-http-url";
import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
@@ -337,6 +338,13 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
this._params.manifest?.is_built_in) ||
!!this._params.manifest?.documentation;
const documentationLink = this._params.manifest?.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._params.manifest.domain}`
)
: this._params.manifest?.documentation;
const dialogTitle = this._getDialogTitle();
const dialogSubtitle = this._getDialogSubtitle();
@@ -368,19 +376,15 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
: nothing
}
${
showDocumentationLink && !this._loading && this._step
showDocumentationLink &&
documentationLink &&
!this._loading &&
this._step
? html`
<a
slot="headerActionItems"
class="help"
href=${
this._params.manifest!.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._params.manifest!.domain}`
)
: this._params.manifest!.documentation
}
href=${documentationLink}
target="_blank"
rel="noreferrer noopener"
>
@@ -542,21 +546,29 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
</ha-button>
</ha-dialog-footer>
`;
case "external":
case "external": {
const externalUrl = sanitizeHttpUrl(this._step.url);
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
href=${this._step.url}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)}
</ha-button>
${
externalUrl
? html`
<ha-button
slot="primaryAction"
href=${externalUrl}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)}
</ha-button>
`
: nothing
}
</ha-dialog-footer>
`;
}
case "create_entry": {
const devices = this._devices(
this._params!.flowConfig.showDevices,
@@ -1,6 +1,7 @@
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { sanitizeHttpUrl } from "../../common/url/sanitize-http-url";
import type { DataEntryFlowStepExternal } from "../../data/data_entry_flow";
import type { HomeAssistant } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
@@ -24,7 +25,11 @@ class StepFlowExternal extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
window.open(this.step.url);
// Opened without user interaction, so only ever follow an http(s) URL
const url = sanitizeHttpUrl(this.step.url);
if (url) {
window.open(url);
}
}
static get styles(): CSSResultGroup {
+27 -2
View File
@@ -1,5 +1,5 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import deepClone from "deep-clone-simple";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
@@ -12,11 +12,13 @@ import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { HassDialog, ShowDialogParams } from "../make-dialog-manager";
import type { FormDialogData, FormDialogParams } from "./show-form-dialog";
import type { HaForm } from "../../components/ha-form/ha-form";
interface StackEntry {
params: FormDialogParams;
data: FormDialogData;
nestedField?: string;
error?: Record<string, string>;
}
@customElement("dialog-form")
@@ -36,10 +38,15 @@ export class DialogForm
@state() private _stack: StackEntry[] = [];
@state() private _error?: Record<string, string>;
@query("ha-form") private _form?: HaForm;
public async showDialog(params: FormDialogParams): Promise<void> {
this._params = params;
this._data = params.data || {};
this._open = true;
this._error = undefined;
this._initDirtyTracking({ type: "deep" }, this._data);
}
@@ -59,11 +66,17 @@ export class DialogForm
const origin = ev.composedPath()[0] as HTMLElement & { name?: string };
this._stack = [
...this._stack,
{ params: this._params!, data: this._data, nestedField: origin?.name },
{
params: this._params!,
data: this._data,
nestedField: origin?.name,
error: this._error,
},
];
const nested = ev.detail.dialogParams as FormDialogParams;
this._params = nested;
this._data = nested?.data || {};
this._error = undefined;
this._initDirtyTracking({ type: "deep" }, this._data);
};
@@ -75,6 +88,7 @@ export class DialogForm
this._stack = this._stack.slice(0, -1);
this._params = prev.params;
this._data = prev.data;
this._error = prev.error;
this._initDirtyTracking({ type: "deep" }, this._data);
return prev.nestedField;
}
@@ -88,10 +102,18 @@ export class DialogForm
this._params = undefined;
this._data = {};
this._open = false;
this._error = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _submit(): void {
if (this._form && !this._form.reportValidity()) {
this._error = {
base: this.hass!.localize("ui.components.form.validation_failed"),
};
return;
}
this._closeState = "submitted";
const submit = this._params?.submit;
const data = this._data;
@@ -119,6 +141,7 @@ export class DialogForm
: data;
this._data = deepClone({ ...this._data, [nestedField]: newValue });
this._error = undefined;
this._updateDirtyState(this._data);
}
@@ -136,6 +159,7 @@ export class DialogForm
private _valueChanged(ev: CustomEvent): void {
this._data = ev.detail.value;
this._error = undefined;
this._updateDirtyState(this._data);
}
@@ -158,6 +182,7 @@ export class DialogForm
.computeHelper=${this._params.computeHelper}
.data=${this._data}
.schema=${this._params.schema}
.error=${this._error}
@value-changed=${this._valueChanged}
@show-dialog=${this._handleNestedShowDialog}
>
@@ -43,12 +43,19 @@ export class DialogHttpPendingConfig
private _interval?: number;
// This dialog must only be dismissed through its own footer buttons
// (confirm / revert / close). This flag is flipped right before such a
// button closes the dialog, so `closeDialog()` can refuse every other
// close request (navigation, back button, `closeAllDialogs`, …).
private _resolved = false;
public showDialog(params: HttpPendingConfigDialogParams): void {
this._params = params;
this._open = true;
this._busy = undefined;
this._error = undefined;
this._reverted = false;
this._resolved = false;
this._startCountdown();
// The field labels live in the config panel fragment, which is not loaded
// yet when this dialog pops up on startup. Load it so the changed-field
@@ -57,6 +64,12 @@ export class DialogHttpPendingConfig
}
public closeDialog(): boolean {
// Refuse programmatic close requests (navigation, back button,
// `closeAllDialogs`) so a pending HTTP config is never left silently
// unresolved. The dialog only closes once the user picks a footer action.
if (!this._resolved) {
return false;
}
this._open = false;
this._stopCountdown();
return true;
@@ -337,6 +350,9 @@ export class DialogHttpPendingConfig
}
private _notifyResolved(): void {
// Mark the dialog as user-resolved so `closeDialog()` is allowed to close
// it; every footer action calls this before setting `_open = false`.
this._resolved = true;
this._params?.onResolved?.();
// The form on Settings > System > Network may be mounted and showing
// stale state; let it know to refetch.
@@ -9,6 +9,7 @@ import { consumeLocalize } from "../../../common/decorators/consume-context-entr
import { transform } from "../../../common/decorators/transform";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -232,6 +233,7 @@ class MoreInfoUpdate extends LitElement {
}
const createBackupTexts = this._computeCreateBackupTexts();
const releaseUrl = sanitizeHttpUrl(this.stateObj.attributes.release_url);
return html`
<div class="content">
@@ -283,14 +285,10 @@ class MoreInfoUpdate extends LitElement {
</div>
${
this.stateObj.attributes.release_url
releaseUrl
? html`<div class="row">
<div class="key">
<a
href=${this.stateObj.attributes.release_url}
target="_blank"
rel="noreferrer"
>
<a href=${releaseUrl} target="_blank" rel="noreferrer">
${this._localize(
"ui.dialogs.more_info_control.update.release_announcement"
)}
+4 -26
View File
@@ -18,38 +18,15 @@
<meta name="referrer" content="same-origin" />
<meta name="theme-color" content="{{ theme_color }}" />
<meta name="color-scheme" content="dark light" />
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
<%= renderTemplate("_style_base.html.template") %>
<style>
@font-face {
font-family: "Roboto Launch Screen";
font-display: block;
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
::view-transition-group(launch-screen) {
animation-duration: var(--ha-animation-duration-normal, 250ms);
animation-timing-function: ease-out;
}
::view-transition-old(launch-screen) {
animation: fade-out var(--ha-animation-duration-normal, 250ms) ease-out;
}
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
height: 100vh;
}
#ha-launch-screen {
font-family: "Roboto Launch Screen", sans-serif;
font-family: ui-sans-serif, system-ui, sans-serif;
position: fixed;
top: 0;
left: 0;
@@ -61,7 +38,8 @@
flex-direction: column;
justify-content: center;
align-items: center;
view-transition-name: launch-screen;
user-select: none;
-webkit-user-select: none;
background-color: var(--primary-background-color, #fafafa);
z-index: 100;
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
@@ -98,7 +76,7 @@
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
flex: 1;
padding-top: 48px;
padding-top: 16px;
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
+6 -2
View File
@@ -1,5 +1,6 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-button";
@@ -59,7 +60,7 @@ export class HaInitPage extends LitElement {
: nothing
}
`
: html`<p>
: html`<p class=${classMap({ "loading-text": !this.migration })}>
${
this.migration
? html`<span class="migration-text"
@@ -68,7 +69,7 @@ export class HaInitPage extends LitElement {
"Database upgrade is in progress, Home Assistant will not start until the upgrade is completed.\n\nThe upgrade may need a long time to complete, please be patient."
}</span
>`
: this.localize?.("ui.init.loading") || "Loading data"
: this.localize?.("ui.init.loading") || "Loading..."
}
</p>`;
}
@@ -120,6 +121,9 @@ export class HaInitPage extends LitElement {
.migration-text {
white-space: pre-line;
}
.loading-text {
opacity: 0.66;
}
`;
}
+57
View File
@@ -1,3 +1,5 @@
import { ContextProvider, createContext } from "@lit/context";
import type { ReactiveController, ReactiveControllerHost } from "lit";
import { ReactiveElement } from "lit";
import { fireEvent } from "../common/dom/fire_event";
@@ -15,6 +17,61 @@ export const panelIsReady = async (element: HTMLElement) => {
fireEvent(element, "hass-panel-ready");
};
export type RegisterChildPanelReady = (ready: Promise<void>) => void;
export const childPanelReadyContext =
createContext<RegisterChildPanelReady>("child-panel-ready");
export class ChildPanelReady implements ReactiveController {
private _promises: Promise<void>[] = [];
private _host: ReactiveControllerHost & HTMLElement;
private _resolveReady?: () => void;
private _completing = false;
public ready = new Promise<void>((resolve) => {
this._resolveReady = resolve;
});
public constructor(host: ReactiveControllerHost & HTMLElement) {
this._host = host;
host.addController(this);
new ContextProvider(host, {
context: childPanelReadyContext,
initialValue: (ready) => this._promises.push(ready),
});
}
public hostUpdated() {
if (this._completing) {
return;
}
this._completing = true;
this._host.removeController(this);
void this._complete();
}
private async _complete() {
// Children created/updated during this render register after the host's
// update commits. Wait for that before snapshotting readiness promises.
await this._host.updateComplete;
await this._waitForPromises();
this._resolveReady?.();
await panelIsReady(this._host);
}
private _waitForPromises(): Promise<void> {
const count = this._promises.length;
return Promise.allSettled(this._promises).then(() =>
this._promises.length > count ? this._waitForPromises() : undefined
);
}
}
export class PanelReady {
public ready?: Promise<void>;
@@ -107,6 +107,9 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
const selectedDomainName = this._params.selectedDomain
? domainToName(this.hass.localize, this._domain!)
: "";
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/integrations/${this._domain}`)
: this._manifest?.documentation;
return html`
<ha-dialog
.open=${this._open}
@@ -139,17 +142,9 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
}
)}
${
this._manifest?.is_built_in ||
this._manifest?.documentation
documentationLink
? html`<a
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._domain}`
)
: this._manifest.documentation
}
href=${documentationLink}
target="_blank"
rel="noreferrer"
>
@@ -42,6 +42,7 @@ import { computeDomain } from "../../../../../common/entity/compute_domain";
import { navigate } from "../../../../../common/navigate";
import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter";
import type { LocalizeKeys } from "../../../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../../../common/url/sanitize-http-url";
import "../../../../../components/buttons/ha-progress-button";
import "../../../../../components/chips/ha-assist-chip";
import "../../../../../components/chips/ha-chip-set";
@@ -549,7 +550,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
"ui.panel.config.apps.dashboard.visit_app_page",
{
name: html`<a
href=${this._currentAddon.url!}
href=${ifDefined(sanitizeHttpUrl(this._currentAddon.url))}
target="_blank"
rel="noreferrer"
>${getAppDisplayName(
@@ -1107,7 +1108,11 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
private get _pathWebui(): string | null {
const addon = this._currentAddon as HassioAddonDetails;
return addon.webui!.replace("[HOST]", document.location.hostname);
return (
sanitizeHttpUrl(
addon.webui!.replace("[HOST]", document.location.hostname)
) ?? null
);
}
private get _computeShowWebUI(): boolean | "" | null {
@@ -185,20 +185,17 @@ export class HaPlatformCondition extends LitElement {
)
);
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/conditions/${this.condition.condition}`)
: this._manifest?.documentation;
return html`
<div class="description">
${description ? html`<p>${description}</p>` : nothing}
${
this._manifest
documentationLink
? html`<a
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/conditions/${this.condition.condition}`
)
: this._manifest.documentation
}
href=${documentationLink}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
@@ -346,17 +346,19 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
maxWidth: "82px",
sortable: true,
groupable: true,
hidden: narrow,
type: "overflow",
title: this.hass.localize("ui.panel.config.automation.picker.state"),
template: (automation) => html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
template: (automation) =>
narrow
? automation.formatted_state
: html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
},
actions: {
lastFixed: true,
@@ -180,20 +180,17 @@ export class HaPlatformTrigger extends LitElement {
)
);
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/triggers/${this.trigger.trigger}`)
: this._manifest?.documentation;
return html`
<div class="description">
${description ? html`<p>${description}</p>` : nothing}
${
this._manifest
documentationLink
? html`<a
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/triggers/${this.trigger.trigger}`
)
: this._manifest.documentation
}
href=${documentationLink}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
@@ -203,12 +203,10 @@ export function getModifiedAtTableColumn<T>(
}
const renderDateTimeColumn = (valueDateTime: number, hass: HomeAssistant) =>
html`${
valueDateTime
? formatShortDateTimeWithConditionalYear(
new Date(valueDateTime * 1000),
hass.locale,
hass.config
)
: nothing
}`;
valueDateTime
? formatShortDateTimeWithConditionalYear(
new Date(valueDateTime * 1000),
hass.locale,
hass.config
)
: nothing;
@@ -3,9 +3,10 @@ import { mdiRestore } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { computeEntityIdFormatExample } from "../../../common/entity/compute_entity_id_format_example";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { debounce } from "../../../common/util/debounce";
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
@@ -23,11 +24,13 @@ import {
type EntityIdFormat,
type EntityIdPart,
} from "../../../data/entity_id_format";
import { fetchSlug } from "../../../data/ws-slugify";
import { haStyle } from "../../../resources/styles";
import { documentationUrl } from "../../../util/documentation-url";
import "./ha-entity-id-format-editor";
const EXAMPLE_DOMAIN = "sensor";
const PREVIEW_DEBOUNCE_MS = 200;
@customElement("ha-config-entity-id-format")
export class HaConfigEntityIdFormat extends LitElement {
@@ -47,6 +50,10 @@ export class HaConfigEntityIdFormat extends LitElement {
@state() private _error?: string;
@state() private _preview?: string;
@state() private _previewError = false;
protected async firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
try {
@@ -57,12 +64,47 @@ export class HaConfigEntityIdFormat extends LitElement {
}
}
private _examples: Record<EntityIdPart, string> = {
area: "Living room",
device: "Thermostat",
entity: "Temperature",
floor: "Ground floor",
};
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (
(changedProps.has("_format") || changedProps.has("_localize")) &&
this._format
) {
if (changedProps.get("_format") === undefined) {
this._updatePreview();
} else {
this._debouncedUpdatePreview();
}
}
}
private _examples = memoizeOne(
(localize: LocalizeFunc): Record<EntityIdPart, string> => ({
area: localize("ui.panel.config.entity_id_format.card.examples.area"),
device: localize("ui.panel.config.entity_id_format.card.examples.device"),
entity: localize("ui.panel.config.entity_id_format.card.examples.entity"),
floor: localize("ui.panel.config.entity_id_format.card.examples.floor"),
})
);
private _debouncedUpdatePreview = debounce(
() => this._updatePreview(),
PREVIEW_DEBOUNCE_MS
);
private async _updatePreview() {
const examples = this._examples(this._localize);
const fullName = this._format!.map((part) => examples[part])
.filter(Boolean)
.join(" ");
try {
const { slug } = await fetchSlug(this._api, fullName);
this._preview = slug;
this._previewError = false;
} catch (_err: any) {
this._previewError = true;
}
}
protected render() {
return html`
@@ -131,13 +173,20 @@ export class HaConfigEntityIdFormat extends LitElement {
}
private _renderPreview() {
const example = computeEntityIdFormatExample(this._format!, this._examples);
return html`
<div class="preview">
<span class="preview-label">
${this._localize("ui.panel.config.entity_id_format.card.preview")}
</span>
<code>${EXAMPLE_DOMAIN}.${example}</code>
${
this._previewError
? html`<ha-alert alert-type="error">
${this._localize(
"ui.panel.config.entity_id_format.card.preview_error"
)}
</ha-alert>`
: html`<code>${EXAMPLE_DOMAIN}.${this._preview ?? "…"}</code>`
}
</div>
`;
}
@@ -36,6 +36,7 @@ import { showQuickBar } from "../../../dialogs/quick-bar/show-dialog-quick-bar";
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
import { showShortcutsDialog } from "../../../dialogs/shortcuts/show-shortcuts-dialog";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import { ChildPanelReady } from "../../../layouts/panel-ready";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@@ -157,6 +158,11 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
total: 0,
};
public constructor() {
super();
new ChildPanelReady(this);
}
private _pages = memoizeOne(
(
cloudStatus,
@@ -1,12 +1,18 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { consume } from "@lit/context";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { filterNavigationPages } from "../../../common/config/filter_navigation_pages";
import "../../../components/ha-card";
import "../../../components/ha-icon-next";
import type { CloudStatus } from "../../../data/cloud";
import { getConfigEntries } from "../../../data/config_entries";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import {
childPanelReadyContext,
type RegisterChildPanelReady,
} from "../../../layouts/panel-ready";
import type { HomeAssistant } from "../../../types";
import "../components/ha-config-navigation-list";
@@ -18,21 +24,53 @@ class HaConfigNavigation extends LitElement {
@property({ attribute: false }) public pages!: PageNavigation[];
@state() private _hasBluetoothConfigEntries = false;
@state() private _visiblePages?: PageNavigation[];
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
getConfigEntries(this.hass, {
domain: "bluetooth",
}).then((bluetoothEntries) => {
this._hasBluetoothConfigEntries = bluetoothEntries.length > 0;
});
private _hasBluetoothConfigEntries = false;
private _bluetoothEntriesLoaded?: Promise<void>;
private _childReadyRegistered = false;
private _filterNavigationPages = memoizeOne(
(
hass: HomeAssistant,
pages: PageNavigation[],
hasBluetoothConfigEntries: boolean
) => filterNavigationPages(hass, pages, { hasBluetoothConfigEntries })
);
@consume({ context: childPanelReadyContext, subscribe: true })
private _registerChildPanelReady?: RegisterChildPanelReady;
protected override updated(changedProps: Map<PropertyKey, unknown>) {
super.updated(changedProps);
const pagesOrHassChanged =
changedProps.has("pages") || changedProps.has("hass");
if (pagesOrHassChanged) {
const ready = this._resolveVisiblePages();
if (!this._childReadyRegistered && this._registerChildPanelReady) {
this._registerChildPanelReady(ready);
this._childReadyRegistered = true;
}
return;
}
if (
!this._childReadyRegistered &&
this._registerChildPanelReady &&
this.pages &&
this.hass
) {
this._registerChildPanelReady(this._resolveVisiblePages());
this._childReadyRegistered = true;
}
}
protected render(): TemplateResult {
const pages = filterNavigationPages(this.hass, this.pages, {
hasBluetoothConfigEntries: this._hasBluetoothConfigEntries,
}).map((page) => ({
const pages = (this._visiblePages ?? []).map((page) => ({
...page,
name:
page.name ||
@@ -75,6 +113,42 @@ class HaConfigNavigation extends LitElement {
`;
}
private _loadBluetoothEntries(): Promise<void> {
if (!this._bluetoothEntriesLoaded) {
this._bluetoothEntriesLoaded = getConfigEntries(this.hass, {
domain: "bluetooth",
})
.then((entries) => {
this._hasBluetoothConfigEntries = entries.length > 0;
})
.catch(() => {
this._hasBluetoothConfigEntries = false;
});
}
return this._bluetoothEntriesLoaded;
}
private async _resolveVisiblePages(): Promise<void> {
if (this.pages.some((page) => page.component === "bluetooth")) {
await this._loadBluetoothEntries();
}
const visiblePages = this._filterNavigationPages(
this.hass,
this.pages,
this._hasBluetoothConfigEntries
);
const currentVisiblePages = this._visiblePages;
if (
!currentVisiblePages ||
visiblePages.length !== currentVisiblePages.length ||
visiblePages.some((page, index) => page !== currentVisiblePages[index])
) {
this._visiblePages = visiblePages;
}
await this.updateComplete;
}
static styles: CSSResultGroup = css`
/* Accessibility */
.visually-hidden {
@@ -96,6 +96,10 @@ import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { isHelperDomain } from "../helpers/const";
import {
isHomeAssistantUrl,
sanitizeLinkUrl,
} from "../../../common/url/sanitize-http-url";
import { createSearchParam } from "../../../common/url/search-params";
import { brandsUrl } from "../../../util/brands-url";
import { fileDownload } from "../../../util/file_download";
@@ -1263,12 +1267,11 @@ export class HaConfigDevicePage extends LitElement {
const deviceActions: DeviceAction[] = [];
const configurationUrlIsHomeAssistant =
device.configuration_url?.startsWith("homeassistant://") || false;
const configurationUrlIsHomeAssistant = isHomeAssistantUrl(
device.configuration_url
);
const configurationUrl = configurationUrlIsHomeAssistant
? device.configuration_url?.replace("homeassistant://", "/")
: device.configuration_url;
const configurationUrl = sanitizeLinkUrl(device.configuration_url);
if (configurationUrl) {
deviceActions.push({
@@ -61,6 +61,10 @@ export class HaEnergyPowerConfig extends LitElement {
}
}
private get _requiredError(): string {
return this.hass.localize("ui.common.error_required");
}
protected render(): TemplateResult {
return html`
<p class="power-section-label">
@@ -119,6 +123,9 @@ export class HaEnergyPowerConfig extends LitElement {
`${this.localizeBaseKey}.power_helper` as LocalizeKeys,
{ unit: this._powerUnits?.join(", ") || "" }
)}
required
.invalid=${!this.powerConfig.stat_rate}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
`
: nothing
@@ -138,11 +145,16 @@ export class HaEnergyPowerConfig extends LitElement {
.helper=${this.hass.localize(
`${this.localizeBaseKey}.type_inverted_description` as LocalizeKeys
)}
required
.invalid=${!this.powerConfig.stat_rate_inverted}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
`
: nothing
}
${
// These two exclude each other, so they keep their clear button
// (and therefore no required marker) to stay swappable.
this.powerType === "two_sensors"
? html`
<ha-statistic-picker
@@ -157,6 +169,8 @@ export class HaEnergyPowerConfig extends LitElement {
this.powerConfig.stat_rate_to,
].filter((id): id is string => Boolean(id))}
@value-changed=${this._fromPowerChanged}
.invalid=${!this.powerConfig.stat_rate_from}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
<ha-statistic-picker
.hass=${this.hass}
@@ -170,6 +184,8 @@ export class HaEnergyPowerConfig extends LitElement {
this.powerConfig.stat_rate_from,
].filter((id): id is string => Boolean(id))}
@value-changed=${this._toPowerChanged}
.invalid=${!this.powerConfig.stat_rate_to}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
`
: nothing
+1
View File
@@ -89,6 +89,7 @@ class HaPanelConfig extends HassRouterPage {
dashboard: {
tag: "ha-config-dashboard",
load: () => import("./dashboard/ha-config-dashboard"),
waitForReady: true,
},
entities: {
tag: "ha-config-entities",
@@ -8,6 +8,7 @@ import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { round } from "../../../common/number/round";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import "../../../components/chart/ha-chart-base";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -228,7 +229,7 @@ class HaConfigHardwareOverview extends SubscribeMixin(LitElement) {
) as ConfigEntry[];
boardId = boardData.board!.hassio_board_id;
boardName = boardData.name;
documentationURL = boardData.url;
documentationURL = sanitizeHttpUrl(boardData.url);
imageURL = hardwareBrandsUrl(
{
category: "boards",
@@ -300,6 +300,7 @@ class HaScheduleForm extends LitElement {
const newValue = { ...this._item };
const endFormatted = formatTime24h(end, this.hass.locale, this.hass.config);
newValue[day] = [...newValue[day]];
newValue[day][index] = {
...newValue[day][index],
from: value.from,
@@ -337,6 +338,7 @@ class HaScheduleForm extends LitElement {
};
if (newDay === day) {
newValue[day] = [...newValue[day]];
newValue[day][index] = event;
} else {
newValue[day].splice(index, 1);
@@ -10,6 +10,10 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../../common/dom/fire_event";
import {
isHomeAssistantUrl,
sanitizeLinkUrl,
} from "../../../common/url/sanitize-http-url";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
@@ -46,6 +50,15 @@ export class HaConfigFlowCard extends LitElement {
protected render(): TemplateResult {
const attention = ATTENTION_SOURCES.includes(this.flow.context.source);
const configurationUrlIsHomeAssistant = isHomeAssistantUrl(
this.flow.context.configuration_url
);
const configurationUrl = sanitizeLinkUrl(
this.flow.context.configuration_url
);
const documentationLink = this.manifest?.is_built_in
? documentationUrl(this.hass, `/integrations/${this.manifest.domain}`)
: this.manifest?.documentation;
return html`
<ha-integration-action-card
class=${classMap({
@@ -78,7 +91,7 @@ export class HaConfigFlowCard extends LitElement {
)}
</ha-button>
${
this.flow.context.configuration_url || this.manifest || attention
configurationUrl || documentationLink || attention
? html`<ha-dropdown
slot="header-button"
placement="bottom-end"
@@ -90,19 +103,12 @@ export class HaConfigFlowCard extends LitElement {
.path=${mdiDotsVertical}
></ha-icon-button>
${
this.flow.context.configuration_url
configurationUrl
? html`<a
href=${this.flow.context.configuration_url.replace(
/^homeassistant:\/\//,
"/"
)}
href=${configurationUrl}
rel="noreferrer"
target=${
this.flow.context.configuration_url.startsWith(
"homeassistant://"
)
? "_self"
: "_blank"
configurationUrlIsHomeAssistant ? "_self" : "_blank"
}
>
<ha-dropdown-item>
@@ -122,16 +128,9 @@ export class HaConfigFlowCard extends LitElement {
: nothing
}
${
this.manifest
documentationLink
? html`<a
href=${
this.manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this.manifest.domain}`
)
: this.manifest.documentation
}
href=${documentationLink}
rel="noreferrer"
target="_blank"
>
@@ -368,21 +368,18 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this.domain
);
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/integrations/${this._manifest.domain}`)
: this._manifest?.documentation;
return html`
<hass-subpage .hass=${this.hass} .narrow=${this.narrow}>
${
this._manifest
documentationLink
? html`
<a
slot="toolbar-icon"
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
: this._manifest.documentation
}
href=${documentationLink}
rel="noreferrer"
target="_blank"
>
@@ -1398,7 +1395,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
font-size: 32px;
font-weight: 700;
line-height: 40px;
text-align: left;
text-align: start;
text-underline-position: from-font;
text-decoration-skip-ink: none;
margin: 0;
@@ -4,6 +4,7 @@ import {
mdiCloseCircle,
mdiProgressClock,
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -11,6 +12,7 @@ import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../../../common/entity/compute_device_name";
import { sanitizeHttpUrl } from "../../../../../common/url/sanitize-http-url";
import { groupBy } from "../../../../../common/util/group-by";
import "../../../../../components/buttons/ha-progress-button";
import type { HaProgressButton } from "../../../../../components/buttons/ha-progress-button";
@@ -25,10 +27,10 @@ import "../../../../../components/ha-settings-row";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/input/ha-input";
import type {
ZWaveJSNodeCapabilities,
ZWaveJSNodeConfigParam,
ZWaveJSNodeConfigParams,
ZWaveJSSetConfigParamResult,
ZwaveJSNodeConfigParameterUpdate,
ZwaveJSNodeMetadata,
} from "../../../../../data/zwave_js";
import {
@@ -37,6 +39,7 @@ import {
fetchZwaveNodeMetadata,
invokeZWaveCCApi,
setZwaveNodeConfigParameter,
subscribeZwaveNodeConfigParameterUpdates,
} from "../../../../../data/zwave_js";
import { showConfirmationDialog } from "../../../../../dialogs/generic/show-dialog-box";
import "../../../../../layouts/hass-error-screen";
@@ -59,7 +62,7 @@ const icons = {
@customElement("zwave_js-node-config")
class ZWaveJSNodeConfig extends LitElement {
public hass!: HomeAssistant;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@@ -83,13 +86,41 @@ class ZWaveJSNodeConfig extends LitElement {
@state() private _resetDialogProgress = false;
private _unsubConfigParamUpdates?: Promise<UnsubscribeFunc>;
private _resultTimeouts: Record<string, number> = {};
public connectedCallback(): void {
super.connectedCallback();
this.deviceId = this.route.path.substr(1);
this._subscribeConfigParameterUpdates();
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._unsubscribeConfigParameterUpdates();
this._clearAllResultTimeouts();
}
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (!changedProps.has("route") || !this.route) {
return;
}
const deviceId = this.route.path.slice(1);
if (deviceId !== this.deviceId) {
this.deviceId = deviceId;
this._config = undefined;
this._clearAllResultTimeouts();
this._results = {};
this._error = undefined;
}
}
protected updated(changedProps: PropertyValues<this>): void {
if (!this._config || changedProps.has("deviceId")) {
if (changedProps.has("deviceId")) {
this._fetchData();
this._subscribeConfigParameterUpdates();
} else if (!this._config) {
this._fetchData();
}
}
@@ -154,8 +185,9 @@ class ZWaveJSNodeConfig extends LitElement {
device_database: html`<a
rel="noreferrer noopener"
href=${
this._nodeMetadata?.device_database_url ||
"https://devices.zwave-js.io"
sanitizeHttpUrl(
this._nodeMetadata?.device_database_url
) || "https://devices.zwave-js.io"
}
target="_blank"
>${this.hass.localize(
@@ -443,6 +475,58 @@ class ZWaveJSNodeConfig extends LitElement {
<p>${item.value}</p>`;
}
private _subscribeConfigParameterUpdates(): void {
this._unsubscribeConfigParameterUpdates();
if (!this.isConnected || !this.hass || !this.deviceId) {
return;
}
this._unsubConfigParamUpdates = subscribeZwaveNodeConfigParameterUpdates(
this.hass,
this.deviceId,
this._handleConfigParameterUpdate
);
this._unsubConfigParamUpdates.catch(() => {
// The backend doesn't support the subscription; the page still works,
// it just won't receive live updates
this._unsubConfigParamUpdates = undefined;
});
}
private _unsubscribeConfigParameterUpdates(): void {
if (this._unsubConfigParamUpdates) {
this._unsubConfigParamUpdates
.then((unsub) => unsub())
.catch(() => {
// The subscription never succeeded, so there is nothing to clean up
});
this._unsubConfigParamUpdates = undefined;
}
}
private _handleConfigParameterUpdate = (
update: ZwaveJSNodeConfigParameterUpdate
): void => {
const param = this._config?.[update.id];
if (!param) {
return;
}
const status = this._results[update.id]?.status;
if (status === "queued") {
// The device applied the queued change; the accepted result is cleared
// after a short delay by _setResult so the success message shows.
// The value was already set optimistically when the change was queued,
// so this runs even if the reported value matches the stored one.
this._setResult(update.id, "accepted");
} else if (status === "error" && param.value !== update.value) {
// The parameter changed, so the previous error no longer applies
this._setResult(update.id, undefined);
}
if (param.value !== update.value) {
param.value = update.value;
this._config = { ...this._config };
}
};
private _isEnumeratedBool(item: ZWaveJSNodeConfigParam): boolean {
// Some Z-Wave config values use a states list with two options where index 0 = Disabled and 1 = Enabled
// We want those to be considered boolean and show a toggle switch
@@ -609,16 +693,38 @@ class ZWaveJSNodeConfig extends LitElement {
}
}
private _clearResultTimeout(key: string): void {
if (key in this._resultTimeouts) {
clearTimeout(this._resultTimeouts[key]);
delete this._resultTimeouts[key];
}
}
private _clearAllResultTimeouts(): void {
Object.values(this._resultTimeouts).forEach((timeout) =>
clearTimeout(timeout)
);
this._resultTimeouts = {};
}
private _setResult(key: string, value: string | undefined) {
this._clearResultTimeout(key);
if (value === undefined) {
delete this._results[key];
this.requestUpdate();
} else {
this._results = { ...this._results, [key]: { status: value } };
if (value === "accepted") {
// Show the success message briefly, then clear it
this._resultTimeouts[key] = window.setTimeout(() => {
this._setResult(key, undefined);
}, 2000);
}
}
}
private _setError(key: string, message: string) {
this._clearResultTimeout(key);
const errorParam = { status: "error", error: message };
this._results = { ...this._results, [key]: errorParam };
}
@@ -634,12 +740,17 @@ class ZWaveJSNodeConfig extends LitElement {
return;
}
let capabilities: ZWaveJSNodeCapabilities | undefined;
[this._nodeMetadata, this._config, capabilities] = await Promise.all([
const [nodeMetadata, config, capabilities] = await Promise.all([
fetchZwaveNodeMetadata(this.hass, device.id),
fetchZwaveNodeConfigParameters(this.hass, device.id),
fetchZwaveNodeCapabilities(this.hass, device.id),
]);
if (device.id !== this.deviceId) {
// The user navigated to another node while the data was loading
return;
}
this._nodeMetadata = nodeMetadata;
this._config = config;
this._canResetAll =
capabilities &&
Object.values(capabilities).some((endpoint) =>
+8 -4
View File
@@ -4,6 +4,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import { extractSearchParam } from "../../../common/url/search-params";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -199,6 +200,9 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
const isHighlighted = this._highlightedPreviewFeature === previewFeatureId;
const feedbackUrl = sanitizeHttpUrl(preview_feature.feedback_url);
const reportIssueUrl = sanitizeHttpUrl(preview_feature.report_issue_url);
// Build description with learn more link if available
const descriptionWithLink = preview_feature.learn_more_url
? `${description}\n\n[${this.hass.localize("ui.panel.config.labs.learn_more")}](${preview_feature.learn_more_url})`
@@ -237,11 +241,11 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
<div class="card-actions">
<div>
${
preview_feature.feedback_url
feedbackUrl
? html`
<ha-button
appearance="plain"
href=${preview_feature.feedback_url}
href=${feedbackUrl}
target="_blank"
rel="noopener noreferrer"
>
@@ -253,11 +257,11 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
: nothing
}
${
preview_feature.report_issue_url
reportIssueUrl
? html`
<ha-button
appearance="plain"
href=${preview_feature.report_issue_url}
href=${reportIssueUrl}
target="_blank"
rel="noopener noreferrer"
>
@@ -25,6 +25,15 @@ import { showToast } from "../../../util/toast";
import type { SystemLogDetailDialogParams } from "./show-dialog-system-log-detail";
import { formatSystemLogTime } from "./util";
/** Compares the host, so a URL that merely contains ours does not pass. */
const isOfficialDocumentationUrl = (url: string): boolean => {
try {
return new URL(url).hostname === "www.home-assistant.io";
} catch (_err) {
return false;
}
};
@customElement("dialog-system-log-detail")
class DialogSystemLogDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -76,7 +85,12 @@ class DialogSystemLogDetail extends LitElement {
this._manifest &&
(this._manifest.is_built_in ||
// Custom components with our official docs should not link to our docs
!this._manifest.documentation.includes("://www.home-assistant.io"));
(!!this._manifest.documentation &&
!isOfficialDocumentationUrl(this._manifest.documentation)));
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/integrations/${this._manifest.domain}`)
: this._manifest?.documentation;
const title = this.hass.localize("ui.panel.config.logs.details", {
level: html`<span class=${item.level}
@@ -124,18 +138,12 @@ class DialogSystemLogDetail extends LitElement {
${
!this._manifest ||
// Can happen with custom integrations
!showDocumentation
!showDocumentation ||
!documentationLink
? ""
: html`
(<a
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
: this._manifest.documentation
}
href=${documentationLink}
target="_blank"
rel="noreferrer"
>${this.hass.localize(
@@ -3,6 +3,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { mainWindow } from "../../../common/dom/get_main_window";
import {
IP_ADDRESS_OR_NETWORK_PATTERN,
IP_ADDRESS_PATTERN,
@@ -24,7 +25,10 @@ import type {
HttpConfig,
HttpConfigWithMeta,
} from "../../../data/http";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@@ -167,6 +171,10 @@ class HaConfigHttpForm extends LitElement {
@state() private _activeConfigType?: ActiveConfigType;
// The built-in default config as reported by core; used to show the default
// port in the helper text instead of a hard-coded value.
@state() private _default?: HttpConfigWithMeta;
// A pending config that was reverted/failed and kept only for display.
@state() private _revertedPending?: HttpConfigWithMeta;
@@ -201,6 +209,8 @@ class HaConfigHttpForm extends LitElement {
const portChanged =
!!this._stable && this._config?.server_port !== this._stable.server_port;
const hasListenAddresses = !!this._config?.server_host?.some(Boolean);
return html`
<ha-card
outlined
@@ -255,6 +265,17 @@ class HaConfigHttpForm extends LitElement {
`
: nothing
}
${
hasListenAddresses
? html`
<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.network.http.server_host_warning"
)}
</ha-alert>
`
: nothing
}
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
@@ -309,12 +330,16 @@ class HaConfigHttpForm extends LitElement {
private async _fetchConfig(): Promise<void> {
try {
const { stable, pending, active_config_type } = await fetchHttpConfig(
this.hass
);
const {
stable,
pending,
active_config_type,
default: defaultConfig,
} = await fetchHttpConfig(this.hass);
this._stable = stable;
this._config = { ...stable };
this._activeConfigType = active_config_type;
this._default = defaultConfig;
// An active trial pending (no error) is handled by the global
// confirm/revert dialog. A pending carrying an error was reverted or
// failed to apply and is kept only so we can surface it here.
@@ -351,6 +376,12 @@ class HaConfigHttpForm extends LitElement {
if ("type" in schema && schema.type === "expandable") {
return "";
}
if (schema.name === "server_port") {
return this.hass.localize(
"ui.panel.config.network.http.helpers.server_port",
{ port: this._default?.server_port ?? 8123 }
);
}
return (
this.hass.localize(
`ui.panel.config.network.http.helpers.${schema.name}` as any
@@ -365,6 +396,63 @@ class HaConfigHttpForm extends LitElement {
this._showNoChanges = false;
}
// Build a link to the new address for an address-changing restart, so the
// user (still on the old address) can jump to it once Home Assistant is back.
// Best-effort: skip Home Assistant Cloud remote UI (Nabu Casa), and skip when
// the current page is not on the old port — that usually means a reverse
// proxy, where swapping the port would point at the wrong place. Even when
// shown, the new address may not be reachable (e.g. a firewall).
private _newAddressUrl(): string | undefined {
if (!this._stable || !this._config) {
return undefined;
}
const loc = mainWindow.location;
if (loc.hostname.endsWith("nabu.casa")) {
return undefined;
}
const oldHttps = !!this._stable.ssl_certificate;
const newHttps = !!this._config.ssl_certificate;
const oldPort = this._stable.server_port ?? (oldHttps ? 443 : 80);
const newPort = this._config.server_port ?? (newHttps ? 443 : 80);
// The reachable address only changes when the scheme or the port changes.
if (oldHttps === newHttps && oldPort === newPort) {
return undefined;
}
const currentPort = loc.port
? Number(loc.port)
: loc.protocol === "https:"
? 443
: 80;
if (currentPort !== oldPort) {
return undefined;
}
const url = new URL(loc.origin);
url.protocol = newHttps ? "https:" : "http:";
url.port = String(newPort);
return url.toString();
}
private _showNewAddress(url: string): void {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.network.http.restart_address.title"
),
text: html`
<p>
${this.hass.localize(
"ui.panel.config.network.http.restart_address.text"
)}
</p>
<a href=${url} rel="noreferrer noopener">${url}</a>
<p class="dialog-note">
${this.hass.localize(
"ui.panel.config.network.http.restart_address.note"
)}
</p>
`,
});
}
private async _save(): Promise<void> {
if (!this._config || !this._stable) {
return;
@@ -393,24 +481,44 @@ class HaConfigHttpForm extends LitElement {
return;
}
// Capture the new address before the restart drops the connection.
const newAddressUrl = this._newAddressUrl();
this._saving = true;
this._error = undefined;
this._fieldErrors = {};
this._showNoChanges = false;
// Drop empty entries from multi-value fields, and omit the field entirely
// once it is empty so the backend applies its default. Otherwise a cleared
// "IP address to bind to" would submit [""] / [], which binds to nothing.
const config = Object.fromEntries(
Object.entries(this._config).map(([key, value]) => {
if (Array.isArray(value)) {
const filtered = value.filter(Boolean);
return [key, filtered.length ? filtered : undefined];
}
return [key, value];
})
) as HttpConfig;
try {
const result = await saveHttpConfig(this.hass, this._config);
const result = await saveHttpConfig(this.hass, config);
if (!result.restart) {
this._showNoChanges = true;
} else if (newAddressUrl) {
// restart === true: a restart is in flight. The reply usually races
// with the connection drop; if we do reach this branch, offer the new
// address so the user can follow along.
this._showNewAddress(newAddressUrl);
}
// restart === true: a restart is in flight. The reply usually races with
// the connection drop; if we do reach this branch, the disconnected
// overlay will appear in moments. Leave the form as is.
} catch (err: any) {
// The restart kills the WS connection before the ack — that's expected.
if (
err?.error?.code === ERR_CONNECTION_LOST ||
err === ERR_CONNECTION_LOST
) {
if (newAddressUrl) {
this._showNewAddress(newAddressUrl);
}
return;
}
this._handleSaveError(err);
@@ -15,6 +15,8 @@ import type { HaInputCopy } from "../../../components/input/ha-input-copy";
import type { CloudStatus } from "../../../data/cloud";
import { fetchCloudStatus } from "../../../data/cloud";
import { saveCoreConfig } from "../../../data/core";
import type { HttpConfigWithMeta } from "../../../data/http";
import { fetchHttpConfig } from "../../../data/http";
import { getNetworkUrls, type NetworkUrls } from "../../../data/network";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../types";
@@ -34,6 +36,12 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
@state() private _internal_url = "";
// Stable HTTP config from core; used to build a realistic internal URL
// placeholder (scheme from SSL, port from the configured/default port).
@state() private _httpConfig?: HttpConfigWithMeta;
@state() private _httpDefault?: HttpConfigWithMeta;
@state() private _cloudStatus?: CloudStatus | null;
@state() private _showCustomExternalUrl = false;
@@ -97,6 +105,28 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
}
}
// Build realistic URL placeholders: use https when an SSL certificate is
// configured, and only show the port when it differs from the scheme
// default (80 for http, 443 for https).
const useHttps = this._httpConfig?.ssl_certificate ? true : httpUseHttps;
const placeholderPort =
this._httpConfig?.server_port ?? this._httpDefault?.server_port;
const placeholderParts = {
protocol: useHttps ? "https" : "http",
port:
placeholderPort && placeholderPort !== (useHttps ? 443 : 80)
? `:${placeholderPort}`
: "",
};
const internalUrlPlaceholder = this.hass.localize(
"ui.panel.config.url.internal_url_placeholder",
placeholderParts
);
const externalUrlPlaceholder = this.hass.localize(
"ui.panel.config.url.external_url_placeholder",
placeholderParts
);
return html`
<ha-card
outlined
@@ -157,7 +187,7 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
data-name="external_url"
type="url"
.maskedToggle=${!(this._showCustomExternalUrl && canEdit)}
placeholder="https://example.duckdns.org:8123"
.placeholder=${externalUrlPlaceholder}
.value=${externalUrl}
.maskedValue=${
this._showCustomExternalUrl && canEdit
@@ -260,9 +290,7 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
data-name="internal_url"
.maskedToggle=${!(this._showCustomInternalUrl && canEdit)}
type="url"
placeholder=${this.hass.localize(
"ui.panel.config.url.internal_url_placeholder"
)}
.placeholder=${internalUrlPlaceholder}
.value=${internalUrl}
.maskedValue=${
this._showCustomInternalUrl && canEdit
@@ -325,6 +353,14 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
this._cloudStatus = null;
}
this._fetchUrls();
// Best-effort: the placeholder still works without it, just without a port.
fetchHttpConfig(this.hass).then(
({ stable, default: defaultConfig }) => {
this._httpConfig = stable;
this._httpDefault = defaultConfig;
},
() => undefined
);
}
private _toggleCloud(ev: Event) {
@@ -4,6 +4,10 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { isNavigationClick } from "../../../common/dom/is-navigation-click";
import {
isHomeAssistantUrl,
sanitizeLinkUrl,
} from "../../../common/url/sanitize-http-url";
import "../../../components/ha-alert";
import "../../../components/ha-dialog";
import "../../../components/ha-button";
@@ -52,8 +56,10 @@ class DialogRepairsIssue extends LitElement {
return nothing;
}
const learnMoreUrlIsHomeAssistant =
this._issue.learn_more_url?.startsWith("homeassistant://") || false;
const learnMoreUrlIsHomeAssistant = isHomeAssistantUrl(
this._issue.learn_more_url
);
const learnMoreUrl = sanitizeLinkUrl(this._issue.learn_more_url);
const dialogTitle =
this.hass.localize(
@@ -127,20 +133,13 @@ class DialogRepairsIssue extends LitElement {
}
</ha-button>
${
this._issue.learn_more_url
learnMoreUrl
? html`
<ha-button
slot="primaryAction"
appearance="filled"
rel="noopener noreferrer"
href=${
learnMoreUrlIsHomeAssistant
? this._issue.learn_more_url.replace(
"homeassistant://",
"/"
)
: this._issue.learn_more_url
}
href=${learnMoreUrl}
.target=${learnMoreUrlIsHomeAssistant ? "" : "_blank"}
@click=${
learnMoreUrlIsHomeAssistant ? this.closeDialog : undefined
@@ -5,6 +5,8 @@ import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { formatDateTime } from "../../../common/datetime/format_date_time";
import { fireEvent } from "../../../common/dom/fire_event";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import { sanitizeNavigationPath } from "../../../common/url/sanitize-navigation-path";
import { copyToClipboard } from "../../../common/util/copy-clipboard";
import { subscribePollingCollection } from "../../../common/util/subscribe-polling";
import "../../../components/ha-alert";
@@ -335,14 +337,15 @@ class DialogSystemInformation extends LitElement {
if (info.type === "pending") {
value = html` <ha-spinner size="small"></ha-spinner> `;
} else if (info.type === "failed") {
const moreInfoUrl = sanitizeHttpUrl(info.more_info);
value = html`
<span class="error">${info.error}</span>${
!info.more_info
!moreInfoUrl
? ""
: html`
<a
href=${info.more_info}
href=${moreInfoUrl}
target="_blank"
rel="noreferrer noopener"
>
@@ -378,18 +381,22 @@ class DialogSystemInformation extends LitElement {
`);
}
if (domain !== "homeassistant") {
// No target, so an in-app path is also a valid destination here
const manageUrl =
sanitizeHttpUrl(domainInfo.manage_url) ??
sanitizeNavigationPath(domainInfo.manage_url);
sections.push(html`
<div class="card-header">
<h3>${domainToName(this.hass.localize, domain)}</h3>
${
!domainInfo.manage_url
!manageUrl
? ""
: html`
<ha-button
appearance="plain"
size="s"
class="manage"
href=${domainInfo.manage_url}
href=${manageUrl}
>
${this.hass.localize(
"ui.panel.config.info.system_health.manage"
@@ -43,11 +43,15 @@ class IntegrationsStartupTime extends LitElement {
<ha-md-list>
${this._setups?.map((setup) => {
const manifest = this._manifests && this._manifests[setup.domain];
const docLink = manifest
? manifest.is_built_in
? documentationUrl(this.hass, `/integrations/${manifest.domain}`)
: manifest.documentation
: "";
const docLink =
(manifest
? manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${manifest.domain}`
)
: manifest.documentation
: "") || "";
const setupSeconds = setup.seconds?.toFixed(2);
return html`
@@ -5,6 +5,7 @@ import { dump, JSON_SCHEMA, load } from "js-yaml";
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
@@ -17,6 +18,8 @@ import {
isTemplate,
} from "../../../../common/string/has-template";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../../common/url/sanitize-http-url";
import { sanitizeNavigationPath } from "../../../../common/url/sanitize-navigation-path";
import { extractSearchParam } from "../../../../common/url/search-params";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import type { HaProgressButton } from "../../../../components/buttons/ha-progress-button";
@@ -562,7 +565,10 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
`
: html`
<a
href=${resolved.url}
href=${ifDefined(
sanitizeHttpUrl(resolved.url) ??
sanitizeNavigationPath(resolved.url)
)}
target="_blank"
rel="noreferrer"
><ha-button>
+356 -214
View File
@@ -1,17 +1,27 @@
import {
mdiRestore,
mdiTrashCanOutline,
mdiViewSplitHorizontal,
mdiViewSplitVertical,
} from "@mdi/js";
import memoizeOne from "memoize-one";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { customElement, property, state } from "lit/decorators";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-code-editor";
import "../../../../components/ha-expansion-panel";
import type { HaIconButtonToolbarItem } from "../../../../components/ha-icon-button-toolbar";
import "../../../../components/ha-label";
import "../../../../components/ha-spinner";
import "../../../../components/ha-split-panel";
import type { HaSplitPanel } from "../../../../components/ha-split-panel";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tip";
import type { RenderTemplateResult } from "../../../../data/ws-templates";
import { subscribeRenderTemplate } from "../../../../data/ws-templates";
@@ -50,11 +60,18 @@ const TEMPLATE_DOCS_LINKS: { key: string; path: string }[] = [
{ key: "docs_functions", path: "/template-functions/" },
];
const STORAGE_KEY_TEMPLATE = "panel-dev-template-template";
const STORAGE_KEY_SPLIT_POSITION = "panel-dev-template-split-position";
const STORAGE_KEY_SPLIT_ORIENTATION = "panel-dev-template-split-orientation";
const DEFAULT_SPLIT_POSITION = 50;
type SplitOrientation = "horizontal" | "vertical";
@customElement("tools-template")
class HaPanelDevTemplate extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _error?: string;
@@ -66,9 +83,9 @@ class HaPanelDevTemplate extends LitElement {
@state() private _unsubRenderTemplate?: Promise<UnsubscribeFunc>;
@state() private _descriptionExpanded = false;
@state() private _splitPosition = DEFAULT_SPLIT_POSITION;
@query("ha-tip") private _editorTip?: HTMLElement;
@state() private _splitOrientation: SplitOrientation = "horizontal";
private _template = "";
@@ -78,8 +95,6 @@ class HaPanelDevTemplate extends LitElement {
// its late-arriving results discarded.
private _subscribeRequestId = 0;
private _tipResizeObserver?: ResizeObserver;
public connectedCallback() {
super.connectedCallback();
if (this._template && !this._unsubRenderTemplate) {
@@ -90,18 +105,25 @@ class HaPanelDevTemplate extends LitElement {
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeTemplate();
this._tipResizeObserver?.disconnect();
this._tipResizeObserver = undefined;
}
protected firstUpdated() {
if (localStorage && localStorage["panel-dev-template-template"]) {
this._template = localStorage["panel-dev-template-template"];
if (localStorage && localStorage[STORAGE_KEY_TEMPLATE]) {
this._template = localStorage[STORAGE_KEY_TEMPLATE];
} else {
this._template = DEMO_TEMPLATE;
}
const storedPosition = localStorage?.[STORAGE_KEY_SPLIT_POSITION];
if (storedPosition) {
const parsed = parseFloat(storedPosition);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 100) {
this._splitPosition = parsed;
}
}
if (localStorage?.[STORAGE_KEY_SPLIT_ORIENTATION] === "vertical") {
this._splitOrientation = "vertical";
}
this._subscribeTemplate();
this._observeTipHeight();
this._inited = true;
}
@@ -114,15 +136,20 @@ class HaPanelDevTemplate extends LitElement {
: "dict"
: type;
const editorCard = this._renderEditorCard();
const resultCard = this._renderResultCard(type, resultType);
// On narrow viewports side-by-side is too cramped, so force the (still
// resizable) stacked layout and hide the orientation toggle.
const orientation = this.narrow ? "vertical" : this._splitOrientation;
return html`
<div class="content">
<div class="about">
<ha-expansion-panel
.header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.about"
)}
outlined
.expanded=${this._descriptionExpanded}
@expanded-changed=${this._expandedChanged}
>
<div class="description">
<p>
@@ -164,92 +191,159 @@ class HaPanelDevTemplate extends LitElement {
</div>
</ha-expansion-panel>
</div>
<div
class="content ${classMap({
layout: !this.narrow,
horizontal: !this.narrow,
})}"
style="--description-expanded: ${this._descriptionExpanded ? 1 : 0}"
>
<ha-card
class="edit-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.editor"
)}
>
<div class="card-content">
<ha-code-editor
mode="jinja2"
.value=${this._template}
.error=${this._error}
autofocus
autocomplete-entities
autocomplete-icons
@value-changed=${this._templateChanged}
dir="ltr"
></ha-code-editor>
</div>
<div class="card-actions">
<ha-button appearance="plain" @click=${this._restoreDemo}>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.reset"
)}
</ha-button>
<ha-button appearance="plain" @click=${this._clear}>
${this.hass.localize("ui.common.clear")}
</ha-button>
</div>
<ha-tip>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.keyboard_tip",
{
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
}
)}
</ha-tip>
</ha-card>
<ha-card
class="render-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result"
)}
>
<div class="card-content ha-scrollbar">
${
this._rendering
? html`<ha-spinner
class="render-spinner"
size="small"
></ha-spinner>`
: ""
}
${
this._error
? html`<ha-alert
alert-type=${this._errorLevel?.toLowerCase() || "error"}
>${this._error}</ha-alert
>`
: nothing
}
${
this._templateResult
? html`<pre
class="rendered ${classMap({
[resultType]: resultType,
})}"
>
<ha-split-panel
class="panes ${orientation === "vertical" ? "vertical" : ""}"
.position=${this._splitPosition}
.orientation=${orientation}
snap="50%"
@wa-reposition=${this._splitRepositioned}
>
<div slot="start" class="pane">${editorCard}</div>
<div slot="end" class="pane">${resultCard}</div>
${this.narrow ? nothing : this._renderOrientationToggle()}
</ha-split-panel>
`;
}
private _renderOrientationToggle() {
const label = this.hass.localize(
this._splitOrientation === "vertical"
? "ui.panel.config.tools.tabs.templates.layout_side_by_side"
: "ui.panel.config.tools.tabs.templates.layout_stacked"
);
return html`
<button
type="button"
slot="divider"
class="divider-toggle"
.title=${label}
aria-label=${label}
@mousedown=${stopPropagation}
@touchstart=${stopPropagation}
@click=${this._toggleOrientation}
>
<ha-svg-icon
.path=${
this._splitOrientation === "vertical"
? mdiViewSplitVertical
: mdiViewSplitHorizontal
}
></ha-svg-icon>
</button>
`;
}
// Reset/clear live in the editor toolbar next to the built-in undo/redo,
// copy, search and fullscreen buttons; the trailing divider separates them.
private _editorToolbarItems = memoizeOne(
(
localize: HomeAssistant["localize"]
): (HaIconButtonToolbarItem | string)[] => [
{
id: "restore-demo",
label: localize("ui.panel.config.tools.tabs.templates.reset"),
path: mdiRestore,
action: () => this._restoreDemo(),
},
{
id: "clear",
label: localize("ui.common.clear"),
path: mdiTrashCanOutline,
action: () => this._clear(),
},
"divider",
]
);
private _renderEditorCard() {
return html`
<ha-card
class="edit-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.editor"
)}
>
<div class="card-content">
<ha-code-editor
mode="jinja2"
.value=${this._template}
.error=${this._error}
.toolbarItems=${this._editorToolbarItems(this.hass.localize)}
autofocus
autocomplete-entities
autocomplete-icons
@value-changed=${this._templateChanged}
dir="ltr"
></ha-code-editor>
</div>
${
this.narrow
? nothing
: html`
<ha-tip>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.keyboard_tip",
{
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
}
)}
</ha-tip>
`
}
</ha-card>
`;
}
private _renderResultCard(type: string, resultType: string) {
const showEmptyState =
!this._error && !this._rendering && !this._template?.trim();
return html`
<ha-card
class="render-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result"
)}
>
<div class="card-content ha-scrollbar">
${
this._rendering
? html`<ha-spinner
class="render-spinner"
size="small"
></ha-spinner>`
: ""
}
${
this._error
? html`<ha-alert
alert-type=${this._errorLevel?.toLowerCase() || "error"}
>${this._error}</ha-alert
>`
: nothing
}
${
showEmptyState
? html`<div class="empty">
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result_placeholder"
)}
</div>`
: this._templateResult
? html`
<ha-label dense>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result_type"
)}:
${resultType}
</ha-label>
<pre class="rendered">
${
type === "object"
? JSON.stringify(this._templateResult.result, null, 2)
: this._templateResult.result
}</pre>
<p>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result_type"
)}:
${resultType}
</p>
${
this._templateResult.listeners.time
? html`
@@ -316,109 +410,179 @@ ${
)}
</span>`
: nothing
}`
}
`
: nothing
}
</div>
</ha-card>
</div>
}
</div>
</ha-card>
`;
}
private _observeTipHeight() {
if (!this._editorTip || this._tipResizeObserver) {
return;
}
this._tipResizeObserver = new ResizeObserver((entries) => {
const height =
entries[0]?.borderBoxSize?.[0]?.blockSize ??
entries[0]?.contentRect.height;
if (height) {
this.style.setProperty("--tip-height", `${height}px`);
}
});
this._tipResizeObserver.observe(this._editorTip);
private _splitRepositioned(ev: Event) {
this._splitPosition = (ev.target as HaSplitPanel).position;
this._storeSplitPosition();
}
private _expandedChanged(
ev: HASSDomEvent<HASSDomEvents["expanded-changed"]>
) {
this._descriptionExpanded = ev.detail.expanded;
private _toggleOrientation() {
this._splitOrientation =
this._splitOrientation === "vertical" ? "horizontal" : "vertical";
if (this._inited) {
localStorage[STORAGE_KEY_SPLIT_ORIENTATION] = this._splitOrientation;
}
}
private _storeSplitPosition = debounce(
() => {
if (!this._inited) {
return;
}
localStorage[STORAGE_KEY_SPLIT_POSITION] = String(this._splitPosition);
},
500,
false
);
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleScrollbar,
css`
:host {
user-select: none;
display: flex;
flex-direction: column;
height: 100%;
}
.content {
gap: var(--ha-space-4);
.about {
flex: none;
padding: var(--ha-space-4);
}
.content:has(ha-expansion-panel) {
padding-bottom: 0;
}
.content.horizontal {
--panel-header-height: calc(
var(--header-height) + 1em * 2 + var(--ha-line-height-normal) *
var(--ha-font-size-m) + 1px + 2px
);
--description-pane-height: calc(
var(--ha-space-4) + 48px +
(
var(--ha-line-height-normal) * var(--ha-font-size-m) * 3 +
var(--ha-space-1) * 2
) *
var(--description-expanded) + var(--ha-card-border-width, 1px) * 2
);
--card-header-height: calc(
var(--ha-space-3) + var(--ha-space-4) +
var(--ha-line-height-expanded) *
var(--ha-card-header-font-size, var(--ha-font-size-2xl))
);
--card-actions-height: calc(1px + var(--ha-space-2) * 2 + 40px);
--tip-height-minimal: calc(
var(--mdc-icon-size, 24px) + var(--ha-space-4)
);
--edit-pane-height: calc(
100vh - var(--panel-header-height) - var(
--description-pane-height
) - var(--ha-space-4) *
2
);
--code-mirror-max-height: calc(
var(--edit-pane-height) - var(--card-header-height) +
var(--ha-space-2) - var(--card-actions-height) - var(
--tip-height,
var(--tip-height-minimal)
) - var(--ha-space-4) - var(--ha-card-border-width, 1px) *
2
);
.about a {
color: var(--primary-color);
}
.panes {
flex: 1;
min-height: 0;
box-sizing: border-box;
padding: var(--ha-space-4);
--ha-split-panel-min: 20%;
--ha-split-panel-max: 80%;
--ha-split-panel-divider-hit-area: var(--ha-space-4);
}
/* On wide viewports we slot our own handle (the orientation toggle)
into the divider, so hide the default grip. On narrow there is no
toggle, so keep the default grip as the resize affordance. */
:host(:not([narrow])) .panes {
--ha-split-panel-grip-display: none;
}
/* Orientation toggle that lives on the divider. Clicking it toggles
orientation; resizing is done by dragging the divider elsewhere. */
.divider-toggle {
position: relative;
z-index: 1;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 24px;
height: 24px;
margin: 0;
padding: 0;
border: 1px solid var(--divider-color);
border-radius: 50%;
background-color: var(--card-background-color);
color: var(--secondary-text-color);
cursor: pointer;
--mdc-icon-size: 16px;
transition:
color var(--ha-animation-duration-fast) ease-out,
border-color var(--ha-animation-duration-fast) ease-out;
}
@media (hover: hover) {
.divider-toggle:hover {
color: var(--primary-color);
border-color: var(--primary-color);
}
}
.divider-toggle:focus-visible {
outline: none;
color: var(--primary-color);
border-color: var(--primary-color);
}
.pane {
display: flex;
min-width: 0;
height: 100%;
box-sizing: border-box;
contain: size;
}
.pane[slot="start"] {
padding-inline-end: var(--ha-space-4);
}
.pane[slot="end"] {
padding-inline-start: var(--ha-space-4);
}
.panes.vertical .pane[slot="start"] {
padding-inline-end: 0;
padding-block-end: var(--ha-space-4);
}
.panes.vertical .pane[slot="end"] {
padding-inline-start: 0;
padding-block-start: var(--ha-space-4);
}
.pane ha-card {
flex: 1;
min-width: 0;
}
ha-card {
margin-bottom: var(--ha-space-4);
display: flex;
flex-direction: column;
height: 100%;
margin: 0;
}
.edit-pane .card-content {
flex: 1;
min-height: 0;
display: flex;
}
.edit-pane ha-code-editor {
flex: 1;
min-height: 0;
width: 100%;
--code-mirror-height: 100%;
}
.render-pane .card-content {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.edit-pane {
direction: var(--direction);
}
.edit-pane a {
color: var(--primary-color);
}
.content.horizontal > * {
width: 50%;
margin-bottom: 0px;
}
.render-spinner {
position: absolute;
top: var(--ha-space-2);
@@ -428,10 +592,24 @@ ${
}
ha-alert {
margin-bottom: var(--ha-space-2);
display: block;
}
.render-pane ha-label {
align-self: flex-start;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 120px;
padding: var(--ha-space-4);
text-align: center;
color: var(--secondary-text-color);
}
.rendered {
font-family: var(--ha-font-family-code);
-webkit-font-smoothing: var(--ha-font-smoothing);
@@ -439,6 +617,7 @@ ${
clear: both;
white-space: pre-wrap;
background-color: var(--secondary-background-color);
border-radius: var(--ha-border-radius-md);
padding: var(--ha-space-2);
margin-top: 0;
margin-bottom: 0;
@@ -447,7 +626,7 @@ ${
p,
ul {
margin-block-end: 0;
margin-block: 0;
}
.description > p {
margin-block-start: 0;
@@ -468,26 +647,6 @@ ${
color: var(--secondary-text-color);
}
.render-pane .card-content {
user-select: text;
}
.content.horizontal .render-pane .card-content {
overflow: auto;
max-height: calc(
var(--code-mirror-max-height) +
47px - var(--ha-card-border-radius, var(--ha-border-radius-lg))
);
}
.content.horizontal .render-pane {
overflow: hidden;
padding-bottom: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
}
.all_listeners {
color: var(--warning-color);
}
@@ -507,19 +666,6 @@ ${
background-color: var(--secondary-background-color);
white-space: nowrap;
}
@media all and (max-width: 870px) {
.content ha-card {
max-width: 100%;
}
}
.card-actions {
display: flex;
}
.card-actions > ha-button:last-child {
margin-inline-start: auto;
}
`,
];
}
@@ -615,7 +761,7 @@ ${
if (!this._inited) {
return;
}
localStorage["panel-dev-template-template"] = this._template;
localStorage[STORAGE_KEY_TEMPLATE] = this._template;
}
private async _restoreDemo() {
@@ -631,7 +777,7 @@ ${
}
this._template = DEMO_TEMPLATE;
this._subscribeTemplate();
delete localStorage["panel-dev-template-template"];
delete localStorage[STORAGE_KEY_TEMPLATE];
}
private async _clear() {
@@ -647,12 +793,8 @@ ${
}
this._unsubscribeTemplate();
this._template = "";
// Reset to empty result. Setting to 'undefined' results in a different visual
// behaviour compared to manually emptying the template input box.
this._templateResult = {
result: "",
listeners: { all: false, entities: [], domains: [], time: false },
};
// An empty template shows the placeholder empty state.
this._templateResult = undefined;
}
}
+13
View File
@@ -130,6 +130,19 @@ class PanelHome extends LitElement {
}
private async _setup() {
void import("../lovelace/strategies/home/home-dashboard-strategy").catch(
() => undefined
);
void import("../lovelace/strategies/home/home-overview-view-strategy")
.then(({ preloadHomeEnergyPreferences }) => {
const path = this.route?.path?.split("/")[1];
return !path || path === "overview"
? preloadHomeEnergyPreferences(this.hass)
: undefined;
})
.catch(() => undefined);
void import("../lovelace/views/hui-sections-view").catch(() => undefined);
this._updateExtraActionItems();
this._loadConfigPromise = this._loadConfig();
await this._loadConfigPromise;
+14
View File
@@ -1,3 +1,4 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { html, css, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
@@ -15,6 +16,19 @@ class HaPanelIframe extends LitElement {
@property({ attribute: false }) panel!: PanelInfo<{ url: string }>;
render() {
// The sandbox keeps allow-same-origin, so a javascript: URL would run in
// the frontend's own origin
if (sanitizeUrl(this.panel.config.url) === "about:blank") {
return html`
<hass-error-screen
.hass=${this.hass}
.narrow=${this.narrow}
error="Unable to load iframes with this URL."
rootnav
></hass-error-screen>
`;
}
if (
location.protocol === "https:" &&
new URL(this.panel.config.url, location.toString()).protocol !== "https:"
+6
View File
@@ -321,6 +321,9 @@ export class HaPanelLogbook extends LitElement {
"entity_id",
"state",
"event_type",
"name",
"message",
"source",
"context_id",
"context_user_id",
"context_event_type",
@@ -339,6 +342,9 @@ export class HaPanelLogbook extends LitElement {
d.entity_id || "",
csvSafeString(d.state),
csvSafeString(d.attributes?.event_type),
csvSafeString(d.name),
csvSafeString(d.message),
csvSafeString(d.source),
d.context_id || "",
d.context_user_id || "",
csvSafeString(d.context_event_type),
@@ -620,6 +620,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
"--tile-color": color,
};
const fixedInfoHeight =
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
return html`
<ha-card style=${styleMap(style)}>
${
@@ -683,6 +686,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
<ha-tile-container
.featurePosition=${featurePosition}
.vertical=${Boolean(this._config.vertical)}
.fixedInfoHeight=${fixedInfoHeight}
.interactive=${Boolean(this._hasCardAction)}
@action=${this._handleAction}
>
@@ -699,6 +703,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
</ha-tile-icon>
<ha-tile-info
slot="info"
class=${ifDefined(
this._config.vertical && fixedInfoHeight ? "twoline" : undefined
)}
.primary=${primary}
.secondary=${secondary}
></ha-tile-info>
@@ -818,6 +825,13 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
justify-content: center;
color: white;
}
ha-tile-info.twoline::part(primary) {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
white-space: normal;
overflow-wrap: anywhere;
}
`,
];
}
@@ -1,3 +1,4 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
@@ -52,6 +53,12 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
throw new Error("URL required");
}
// The sandbox keeps allow-same-origin, so a javascript: URL would run in
// the frontend's own origin
if (sanitizeUrl(config.url) === "about:blank") {
throw new Error("Invalid URL");
}
this._config = config;
}
@@ -40,6 +40,8 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public layout?: string;
@state() private _config?: ShortcutCardConfig;
private _navInfo = new NavigationPathInfoController(this);
@@ -128,10 +130,14 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
const style = color ? { "--tile-color": color } : {};
const fixedInfoHeight =
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
return html`
<ha-card style=${styleMap(style)}>
<ha-tile-container
.vertical=${Boolean(this._config.vertical)}
.fixedInfoHeight=${fixedInfoHeight}
.interactive=${this._hasCardAction}
.actionHandlerOptions=${{
hasHold: hasAction(this._config.hold_action),
@@ -81,6 +81,8 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public layout?: string;
@state() private _config?: TileCardConfig;
@state() private _featureContext: LovelaceCardFeatureContext = {};
@@ -288,11 +290,15 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
const hasImage = Boolean(imageUrl);
const fixedInfoHeight =
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
return html`
<ha-card style=${styleMap(style)} class=${classMap({ active })}>
<ha-tile-container
.featurePosition=${featurePosition}
.vertical=${Boolean(this._config.vertical)}
.fixedInfoHeight=${fixedInfoHeight}
.interactive=${this._hasCardAction}
.actionHandlerOptions=${{
hasHold: hasAction(this._config!.hold_action),
+2 -1
View File
@@ -1,3 +1,4 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { forwardHaptic } from "../../../data/haptics";
@@ -133,7 +134,7 @@ export const handleAction = async (
break;
case "url": {
if (actionConfig.url_path) {
window.open(actionConfig.url_path);
window.open(sanitizeUrl(actionConfig.url_path));
} else {
showToast(node, {
message: hass.localize("ui.panel.lovelace.cards.actions.no_url"),
@@ -54,7 +54,7 @@ export class HuiNumericInputCardFeatureEditor
}
const data: NumericInputCardFeatureConfig = {
style: "buttons",
style: "slider",
...this._config,
};
@@ -1,3 +1,4 @@
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import {
getCustomBadgeEntry,
getCustomCardEntry,
@@ -38,7 +39,9 @@ export const getCardDocumentationURL = (
type: string
): string | undefined => {
if (isCustomType(type)) {
return getCustomCardEntry(stripCustomPrefix(type))?.documentationURL;
return sanitizeHttpUrl(
getCustomCardEntry(stripCustomPrefix(type))?.documentationURL
);
}
return `${documentationUrl(hass, "/dashboards/")}${NON_STANDARD_CARD_URLS[type] || type}`;
@@ -49,7 +52,9 @@ export const getBadgeDocumentationURL = (
type: string
): string | undefined => {
if (isCustomType(type)) {
return getCustomBadgeEntry(stripCustomPrefix(type))?.documentationURL;
return sanitizeHttpUrl(
getCustomBadgeEntry(stripCustomPrefix(type))?.documentationURL
);
}
return `${documentationUrl(hass, "/dashboards/")}${NON_STANDARD_BADGE_URLS[type] || "badges"}`;
@@ -1,3 +1,4 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
@@ -16,6 +17,11 @@ class HuiWeblinkRow extends LitElement implements LovelaceRow {
throw new Error("URL required");
}
// Reject schemes that would run script in the frontend's origin
if (sanitizeUrl(config.url) === "about:blank") {
throw new Error("Invalid URL");
}
this._config = {
icon: "mdi:link",
name: config.url,
@@ -9,6 +9,7 @@ import {
} from "../../../../common/entity/entity_filter";
import { floorDefaultIcon } from "../../../../components/ha-floor-icon";
import type { AreaRegistryEntry } from "../../../../data/area/area_registry";
import type { EnergyPreferences } from "../../../../data/energy";
import { getEnergyPreferences } from "../../../../data/energy";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type {
@@ -50,6 +51,26 @@ export interface HomeOverviewViewStrategyConfig {
shortcuts?: ShortcutItem[];
}
const energyPreferencesPromises = new WeakMap<
HomeAssistant["connection"],
Promise<EnergyPreferences | undefined>
>();
export const preloadHomeEnergyPreferences = (hass: HomeAssistant) => {
if (!isComponentLoaded(hass.config, "energy")) {
return Promise.resolve(undefined);
}
const existing = energyPreferencesPromises.get(hass.connection);
if (existing) {
return existing;
}
const request = getEnergyPreferences(hass).catch(() => undefined);
energyPreferencesPromises.set(hass.connection, request);
return request;
};
const computeAreaCard = (
areaId: string,
hass: HomeAssistant
@@ -305,10 +326,8 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
.filter(weatherFilter)
.sort()[0];
const energyPrefs = isComponentLoaded(hass.config, "energy")
? // It raises if not configured, just swallow that.
await getEnergyPreferences(hass).catch(() => undefined)
: undefined;
const energyPrefs = await preloadHomeEnergyPreferences(hass);
energyPreferencesPromises.delete(hass.connection);
const hasEnergy =
hass.panels.energy &&
+1 -7
View File
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
@@ -148,12 +148,6 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
this._migrating = false;
}
}
static styles = css`
a {
color: var(--primary-color);
}
`;
}
declare global {
+3
View File
@@ -8,6 +8,7 @@ import type { HASSDomEvent } from "../common/dom/fire_event";
import { subscribeThemePreferences, saveThemePreferences } from "../data/theme";
import { subscribeThemes } from "../data/ws-themes";
import type { Constructor, HomeAssistant } from "../types";
import { updateLaunchScreenLogo } from "../util/launch-screen";
import { storeState } from "../util/ha-pref-storage";
import type { HassBaseEl } from "./hass-base-mixin";
@@ -145,6 +146,8 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
true
);
updateLaunchScreenLogo(darkMode);
if (darkMode !== this.hass.themes.darkMode) {
this._updateHass({
themes: { ...this.hass.themes!, darkMode },
+24 -4
View File
@@ -75,7 +75,7 @@
},
"ui": {
"init": {
"loading": "Loading data",
"loading": "Loading...",
"migration": "Database upgrade is in progress, Home Assistant will not start until the upgrade is completed.\n\nThe upgrade may need a long time to complete, please be patient.",
"project_from": "A project from the",
"error": {
@@ -522,6 +522,9 @@
"show_password": "Show password",
"hide_password": "Hide password"
},
"form": {
"validation_failed": "Form validation failed."
},
"selectors": {
"serial_port": {
"enter_manually": "Enter manually",
@@ -2396,7 +2399,7 @@
"header": "Copy pairing code",
"step_1": "[%key:ui::dialogs::matter-add-device::google_home::step_1%]",
"step_2": "[%key:ui::dialogs::matter-add-device::google_home::step_2%]",
"step_3": "Tap {link_apps_services} and choose {use_pairing_code} form the list",
"step_3": "Tap {link_apps_services} and choose {use_pairing_code} from the list",
"linked_matter_apps_services": "[%key:ui::dialogs::matter-add-device::google_home::linked_matter_apps_services%]",
"link_apps_services": "[%key:ui::dialogs::matter-add-device::google_home::link_apps_services%]",
"use_pairing_code": "Use Pairing Code",
@@ -3955,6 +3958,9 @@
"about": "About templates",
"editor": "Template editor",
"result": "Result",
"result_placeholder": "Your template result will appear here.",
"layout_stacked": "Drag to resize, click for stacked view",
"layout_side_by_side": "Drag to resize, click for side-by-side view",
"reset": "Reset to demo template",
"confirm_reset": "Do you want to reset your current template back to the demo template?",
"confirm_clear": "Do you want to clear your current template?",
@@ -4476,7 +4482,8 @@
"internal_url_https_error_title": "Invalid local network URL",
"internal_url_https_error_description": "You have configured an HTTPS certificate in Home Assistant. This means that your internal URL needs to be set to a domain covered by the certificate.",
"internal_url_automatic_description": "Use the configured network settings",
"internal_url_placeholder": "http://<some IP address>:8123",
"internal_url_placeholder": "{protocol}://192.168.1.100{port}",
"external_url_placeholder": "{protocol}://example.duckdns.org{port}",
"invalid_url": "Invalid URL"
},
"hardware": {
@@ -8648,7 +8655,14 @@
"description": "Entity IDs are used to reference entities in automations, scripts, and dashboards. This format only applies when a new entity is created. Using it is optional, and you can still rename each entity ID afterwards in its settings",
"learn_more": "Learn more",
"preview": "Preview",
"preview_error": "Failed to generate preview",
"reset": "Reset to default",
"examples": {
"area": "Living room",
"device": "Thermostat",
"entity": "Temperature",
"floor": "Ground floor"
},
"editor": {
"label": "Format",
"add": "Add",
@@ -8748,6 +8762,7 @@
"save_no_changes": "Nothing changed — no restart needed.",
"save_error": "Could not save the HTTP configuration.",
"port_warning": "Clients such as the Home Assistant mobile apps will lose their connection until you update the URL in their settings. If Home Assistant is not confirmed reachable on the new port, the change is rolled back automatically after 5 minutes.",
"server_host_warning": "Leave this empty to listen on all interfaces. If you set specific addresses, Home Assistant is only reachable through those — binding it only to an address that other devices can't reach (for example localhost) will make it unreachable from those devices.",
"invalid_host": "Enter a valid IP address.",
"invalid_network": "Enter a valid IP address or network.",
"running_default": "Your saved HTTP configuration could not be applied, so Home Assistant is running on the built-in default configuration.",
@@ -8759,6 +8774,11 @@
"text": "Saving will restart Home Assistant to apply the new HTTP settings.",
"confirm": "Save and restart"
},
"restart_address": {
"title": "Home Assistant is restarting",
"text": "The address changed. Once Home Assistant has restarted, you can reach it at:",
"note": "Depending on your network setup, this address may not be reachable from this device."
},
"ssl_profile_modern": "Modern",
"ssl_profile_intermediate": "Intermediate",
"sections": {
@@ -8782,7 +8802,7 @@
"login_attempts_threshold": "Login attempts before ban"
},
"helpers": {
"server_port": "The port Home Assistant listens on. Default is 8123.",
"server_port": "The port Home Assistant listens on. Default is {port}.",
"server_host": "IP addresses to bind to. Leave empty to listen on all interfaces.",
"ssl_certificate": "Absolute path to your TLS certificate (for example, /ssl/fullchain.pem).",
"ssl_key": "Absolute path to your TLS private key (for example, /ssl/privkey.pem).",
+24 -18
View File
@@ -1,12 +1,11 @@
import type { TemplateResult } from "lit";
import { render } from "lit";
import { parseAnimationDuration } from "../common/util/parse-animation-duration";
import { withViewTransition } from "../common/util/view-transition";
let removalInitiated = false;
/**
* Removes the launch screen with a fade-out view transition.
* Removes the launch screen with a CSS fade-out transition.
*
* @param instant - Removes the launch screen without animation. Used when the
* external app covers the frontend with its own splash screen until the
@@ -26,23 +25,16 @@ export const removeLaunchScreen = (instant = false): boolean => {
return true;
}
withViewTransition((viewTransitionAvailable) => {
if (viewTransitionAvailable) {
launchScreenElement.classList.add("removing");
const durationFromCss = getComputedStyle(document.documentElement)
.getPropertyValue("--ha-animation-duration-normal")
.trim();
setTimeout(
() => {
launchScreenElement.parentElement?.removeChild(launchScreenElement);
return;
}
launchScreenElement.classList.add("removing");
const durationFromCss = getComputedStyle(document.documentElement)
.getPropertyValue("--ha-animation-duration-normal")
.trim();
setTimeout(
() => {
launchScreenElement.parentElement?.removeChild(launchScreenElement);
},
parseAnimationDuration(durationFromCss || "250ms")
);
});
},
parseAnimationDuration(durationFromCss || "250ms")
);
return true;
};
@@ -57,6 +49,20 @@ export const renderLaunchScreenContent = (
updateLaunchScreenAttribution(attribution);
};
/**
* Switches the launch screen OHF logo to the variant matching the applied
* theme. The `<picture>` element initially picks a variant based on the system
* color scheme, which can differ from the theme the frontend ends up applying.
*/
export const updateLaunchScreenLogo = (darkMode: boolean) => {
const logoSourceElement = document.querySelector<HTMLSourceElement>(
"#ha-launch-screen .ohf-logo source"
);
if (logoSourceElement) {
logoSourceElement.media = darkMode ? "all" : "not all";
}
};
export const updateLaunchScreenAttribution = (attribution: string) => {
const attributionElement = document.getElementById(
"ha-launch-screen-attribution"
+139
View File
@@ -0,0 +1,139 @@
/**
* @vitest-environment node
*/
import { execFile, spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { processStartTime } from "../../build-scripts/managed-process.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../.."
);
const temporaryDirectories = [];
const runNode = (args, env = {}) =>
new Promise((resolve) => {
execFile(
process.execPath,
args,
{
cwd: repoRoot,
env: { ...process.env, ...env },
},
(error, stdout, stderr) => {
resolve({ code: error?.code ?? 0, stdout, stderr });
}
);
});
const temporaryDirectory = async () => {
const directory = await mkdtemp(path.join(tmpdir(), "ha-build-cli-test-"));
temporaryDirectories.push(directory);
return directory;
};
const writeOwner = async (owner) => {
const cache = await temporaryDirectory();
const lockFile = path.join(cache, "ha-workflow.lock");
const record = {
pid: process.pid,
startTime: processStartTime(process.pid),
...owner,
};
await writeFile(lockFile, JSON.stringify(record));
return { cache, lockFile, record };
};
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true }))
);
});
describe("build management CLIs", () => {
it("blocks a managed workflow when another owns the output", async () => {
const { cache } = await writeOwner({ kind: "build", token: "build" });
const result = await runNode(
["build-scripts/dev-server.mjs", "--suite", "app", "--background"],
{ HA_BUILD_CACHE_DIR: cache }
);
expect(result.code).toBe(1);
expect(result.stdout).toContain("Frontend build already running");
});
it("does not stop another development suite", async () => {
const { cache, lockFile, record } = await writeOwner({
kind: "dev",
suite: "demo",
token: "demo",
});
const result = await runNode(
["build-scripts/dev-server.mjs", "--suite", "gallery", "--stop"],
{ HA_BUILD_CACHE_DIR: cache }
);
expect(result.code).toBe(0);
expect(result.stdout).toContain("Dev server (gallery) not running");
expect(JSON.parse(await readFile(lockFile, "utf8"))).toEqual(record);
});
it("keeps an exact process suite start idempotent", async () => {
const { cache } = await writeOwner({
kind: "dev",
suite: "app",
token: "app",
});
const result = await runNode(
["build-scripts/dev-server.mjs", "--suite", "app", "--background"],
{ HA_BUILD_CACHE_DIR: cache }
);
expect(result.code).toBe(0);
expect(result.stdout).toContain("Dev server (app) already running");
});
});
describe("workflow ownership", () => {
it("removes owned locks when the child is terminated", async () => {
const cache = await temporaryDirectory();
const lockFile = path.join(cache, "ha-workflow.lock");
const script = [
'import { createWorkflowLockTask } from "./build-scripts/output-lock.mjs";',
'await createWorkflowLockTask("test")();',
'process.stdout.write("ready\\n");',
"setInterval(() => {}, 10000);",
].join("");
const child = spawn(
process.execPath,
["--input-type=module", "-e", script],
{
cwd: repoRoot,
env: { ...process.env, HA_BUILD_CACHE_DIR: cache },
stdio: ["ignore", "pipe", "inherit"],
}
);
await new Promise((resolve, reject) => {
child.stdout.once("data", resolve);
child.once("error", reject);
});
child.kill("SIGTERM");
await new Promise((resolve) => {
child.once("exit", resolve);
});
await expect(readFile(lockFile)).rejects.toMatchObject({ code: "ENOENT" });
});
});
@@ -0,0 +1,64 @@
/**
* @vitest-environment node
*/
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import process from "node:process";
import { afterEach, describe, expect, it } from "vitest";
import {
acquireProcessRecord,
processStartTime,
readProcessRecord,
releaseProcessRecord,
} from "../../build-scripts/managed-process.mjs";
const temporaryDirectories = [];
const temporaryFile = async (name) => {
const directory = await mkdtemp(path.join(tmpdir(), "ha-build-test-"));
temporaryDirectories.push(directory);
return path.join(directory, name);
};
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true }))
);
});
describe("managed process records", () => {
it("recovers a stale owner", async () => {
const file = await temporaryFile("stale.lock");
await writeFile(
file,
JSON.stringify({ pid: 2147483647, startTime: "stale", token: "stale" })
);
const owner = {
pid: process.pid,
startTime: processStartTime(process.pid),
token: "current",
};
expect(acquireProcessRecord(file, owner).acquired).toBe(true);
expect(readProcessRecord(file)).toEqual(owner);
});
it("releases only for the owning token", async () => {
const file = await temporaryFile("token.lock");
const owner = {
pid: process.pid,
startTime: processStartTime(process.pid),
token: "owner",
};
acquireProcessRecord(file, owner);
releaseProcessRecord(file, "other");
expect(readProcessRecord(file)).toEqual(owner);
releaseProcessRecord(file, owner.token);
expect(readProcessRecord(file)).toBeUndefined();
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import {
homeAssistantUrlToPath,
isHomeAssistantUrl,
sanitizeLinkUrl,
sanitizeHttpUrl,
} from "../../../src/common/url/sanitize-http-url";
describe("sanitizeHttpUrl", () => {
it("keeps http and https URLs", () => {
expect(
sanitizeHttpUrl("https://www.home-assistant.io/integrations/hue")
).toEqual("https://www.home-assistant.io/integrations/hue");
expect(sanitizeHttpUrl("http://192.168.1.5:8080/setup")).toEqual(
"http://192.168.1.5:8080/setup"
);
});
/* eslint-disable no-script-url */
it("rejects URIs that can execute script", () => {
expect(sanitizeHttpUrl("javascript:alert(1)")).toBeUndefined();
expect(sanitizeHttpUrl("JavaScript:alert(1)")).toBeUndefined();
expect(sanitizeHttpUrl("java\tscript:alert(1)")).toBeUndefined();
expect(sanitizeHttpUrl(" javascript:alert(1)")).toBeUndefined();
expect(
sanitizeHttpUrl("data:text/html,<script>alert(1)</script>")
).toBeUndefined();
expect(sanitizeHttpUrl("vbscript:msgbox(1)")).toBeUndefined();
});
/* eslint-enable no-script-url */
it("rejects other schemes and unparseable values", () => {
expect(sanitizeHttpUrl("homeassistant://config/system")).toBeUndefined();
expect(sanitizeHttpUrl("file:///etc/passwd")).toBeUndefined();
expect(sanitizeHttpUrl("about:blank")).toBeUndefined();
expect(sanitizeHttpUrl("/config/system")).toBeUndefined();
expect(sanitizeHttpUrl("not a url")).toBeUndefined();
});
it("rejects missing values", () => {
expect(sanitizeHttpUrl(undefined)).toBeUndefined();
expect(sanitizeHttpUrl(null)).toBeUndefined();
expect(sanitizeHttpUrl("")).toBeUndefined();
});
});
describe("isHomeAssistantUrl", () => {
it("detects the Home Assistant scheme", () => {
expect(isHomeAssistantUrl("homeassistant://config/system")).toBe(true);
expect(isHomeAssistantUrl("https://www.home-assistant.io/")).toBe(false);
expect(isHomeAssistantUrl(undefined)).toBe(false);
});
});
describe("homeAssistantUrlToPath", () => {
it("rewrites a deep link to an in-app path", () => {
expect(homeAssistantUrlToPath("homeassistant://config/system")).toEqual(
"/config/system"
);
expect(homeAssistantUrlToPath("homeassistant://config/network")).toEqual(
"/config/network"
);
});
it("rejects a deep link that leaves the frontend", () => {
// A plain scheme rewrite would turn this into "//example.com".
expect(
homeAssistantUrlToPath("homeassistant:///example.com")
).toBeUndefined();
expect(
homeAssistantUrlToPath("homeassistant:///\\example.com")
).toBeUndefined();
});
it("rejects anything that is not a deep link", () => {
expect(homeAssistantUrlToPath("https://example.com/")).toBeUndefined();
expect(homeAssistantUrlToPath(undefined)).toBeUndefined();
});
});
describe("sanitizeLinkUrl", () => {
it("handles both external links and deep links", () => {
expect(sanitizeLinkUrl("https://example.com/docs")).toEqual(
"https://example.com/docs"
);
expect(sanitizeLinkUrl("homeassistant://config/system")).toEqual(
"/config/system"
);
// eslint-disable-next-line no-script-url
expect(sanitizeLinkUrl("javascript:alert(1)")).toBeUndefined();
expect(sanitizeLinkUrl("homeassistant:///example.com")).toBeUndefined();
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { html, nothing, render } from "lit";
import "../../src/components/data-table/ha-data-table";
import type {
DataTableColumnContainer,
DataTableRowData,
HaDataTable,
} from "../../src/components/data-table/ha-data-table";
const columns: DataTableColumnContainer = {
name: { title: "Name", main: true },
area: { title: "Area" },
category: { title: "Category" },
empty_template: { title: "Empty", template: () => nothing },
filled_template: { title: "Filled", template: () => html`filled` },
};
// The narrow row puts every non-main column on a secondary line, joined by dots.
const renderNarrowSecondary = (row: DataTableRowData) => {
const el = document.createElement("ha-data-table") as HaDataTable;
const container = document.createElement("div");
render((el as any)._renderRow(columns, true, row, 0), container);
return container.querySelector(".secondary")!.textContent!.trim();
};
describe("ha-data-table narrow secondary line", () => {
it("does not render separators for empty columns", () => {
expect(renderNarrowSecondary({ id: "1", name: "Test" })).toBe("filled");
});
it("separates only the columns that have a value", () => {
expect(
renderNarrowSecondary({ id: "1", name: "Test", area: "Kitchen" })
).toBe("Kitchen · filled");
});
it("renders a blank secondary line when all secondary columns are empty", () => {
const emptyColumns: DataTableColumnContainer = {
name: { title: "Name", main: true },
area: { title: "Area" },
category: { title: "Category" },
empty_template: { title: "Empty", template: () => nothing },
};
const el = document.createElement("ha-data-table") as HaDataTable;
const container = document.createElement("div");
render(
(el as any)._renderRow(emptyColumns, true, { id: "1", name: "Test" }, 0),
container
);
expect(container.querySelector(".secondary")!.textContent!.trim()).toBe("");
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import type { IntegrationManifest } from "../../src/data/integration";
import {
fetchIntegrationManifest,
fetchIntegrationManifests,
integrationIssuesUrl,
} from "../../src/data/integration";
import type { HomeAssistant } from "../../src/types";
const manifest = (
overrides: Partial<IntegrationManifest> = {}
): IntegrationManifest =>
({
domain: "evil",
name: "Evil",
is_built_in: false,
config_flow: false,
iot_class: "local_polling",
documentation: "https://example.com/docs",
...overrides,
}) as IntegrationManifest;
const hassWith = (result: unknown) =>
({ callWS: vi.fn().mockResolvedValue(result) }) as unknown as HomeAssistant;
// A custom integration ships its own manifest, so these URLs are untrusted.
/* eslint-disable no-script-url */
const UNSAFE_URL = "javascript:alert(1)";
describe("integration manifests", () => {
it("strips unsafe URLs from a fetched list", async () => {
const hass = hassWith([
manifest({ documentation: UNSAFE_URL, issue_tracker: UNSAFE_URL }),
]);
const [fetched] = await fetchIntegrationManifests(hass);
expect(fetched.documentation).toBeUndefined();
expect(fetched.issue_tracker).toBeUndefined();
});
it("strips unsafe URLs from a single fetched manifest", async () => {
const hass = hassWith(manifest({ documentation: UNSAFE_URL }));
expect((await fetchIntegrationManifest(hass, "evil"))!.documentation).toBe(
undefined
);
});
it("keeps http and https URLs", async () => {
const hass = hassWith([
manifest({
documentation: "https://example.com/docs",
issue_tracker: "http://example.com/issues",
}),
]);
const [fetched] = await fetchIntegrationManifests(hass);
expect(fetched.documentation).toEqual("https://example.com/docs");
expect(fetched.issue_tracker).toEqual("http://example.com/issues");
});
it("does not mutate the received manifest", async () => {
const received = manifest({ documentation: UNSAFE_URL });
const hass = hassWith([received]);
await fetchIntegrationManifests(hass);
expect(received.documentation).toEqual(UNSAFE_URL);
});
it("falls back to the core issue tracker for an unsafe issue_tracker", () => {
expect(
integrationIssuesUrl("evil", manifest({ issue_tracker: UNSAFE_URL }))
).toContain("https://github.com/home-assistant/core/issues");
});
});
/* eslint-enable no-script-url */
@@ -1,5 +1,12 @@
import { render } from "lit";
import { assert, describe, it } from "vitest";
import { getPowerHelperEntityId } from "../../../../src/panels/config/energy/dialogs/power-config";
import "../../../../src/panels/config/energy/dialogs/ha-energy-power-config";
import type { HaEnergyPowerConfig } from "../../../../src/panels/config/energy/dialogs/ha-energy-power-config";
import type { PowerConfig } from "../../../../src/data/energy";
import {
getPowerHelperEntityId,
type PowerType,
} from "../../../../src/panels/config/energy/dialogs/power-config";
describe("getPowerHelperEntityId", () => {
it("returns the helper for an inverted config", () => {
@@ -69,3 +76,100 @@ describe("getPowerHelperEntityId", () => {
);
});
});
// Renders the template directly so the async unit lookup in willUpdate is
// skipped. localize echoes the key back.
const renderPickers = (powerType: PowerType, powerConfig: PowerConfig) => {
const el = document.createElement(
"ha-energy-power-config"
) as HaEnergyPowerConfig;
el.hass = { localize: (key: string) => key } as any;
el.powerType = powerType;
el.powerConfig = powerConfig;
const container = document.createElement("div");
render((el as any).render(), container);
return [...container.querySelectorAll("ha-statistic-picker")].map(
(picker: any) => ({
required: picker.required,
invalid: picker.invalid,
errorMessage: picker.errorMessage,
})
);
};
describe("ha-energy-power-config required power statistic", () => {
it("renders no picker when no power sensor is configured", () => {
assert.lengthOf(renderPickers("none", {}), 0);
});
it("marks an empty standard statistic as required and invalid", () => {
assert.deepEqual(renderPickers("standard", {}), [
{
required: true,
invalid: true,
errorMessage: "ui.common.error_required",
},
]);
});
it("keeps the statistic required but valid once it is set", () => {
const [picker] = renderPickers("standard", { stat_rate: "sensor.power" });
assert.isTrue(picker.required);
assert.isFalse(picker.invalid);
});
it("marks an empty inverted statistic as required and invalid", () => {
const [picker] = renderPickers("inverted", {});
assert.isTrue(picker.required);
assert.isTrue(picker.invalid);
});
it("does not flag the inverted statistic when it is set", () => {
const [picker] = renderPickers("inverted", {
stat_rate_inverted: "sensor.power",
});
assert.isFalse(picker.invalid);
});
it("flags both two sensor statistics while they are empty", () => {
const pickers = renderPickers("two_sensors", {});
assert.lengthOf(pickers, 2);
assert.deepEqual(
pickers.map((p) => p.invalid),
[true, true]
);
});
// The two sensor statistics exclude each other, so they keep their clear
// button — and therefore no required marker — to stay swappable.
it("does not mark the two sensor statistics as required", () => {
const pickers = renderPickers("two_sensors", {});
assert.deepEqual(
pickers.map((p) => p.required),
[false, false]
);
});
it("flags only the statistic that is still missing", () => {
const pickers = renderPickers("two_sensors", {
stat_rate_from: "sensor.power_from",
});
assert.deepEqual(
pickers.map((p) => p.invalid),
[false, true]
);
});
it("clears both flags once the two sensor pair is complete", () => {
const pickers = renderPickers("two_sensors", {
stat_rate_from: "sensor.power_from",
stat_rate_to: "sensor.power_to",
});
assert.deepEqual(
pickers.map((p) => p.invalid),
[false, false]
);
});
});
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
import type { HomeAssistant } from "../../../src/types";
const hass = { localize: (key: string) => key } as unknown as HomeAssistant;
const openUrl = (url: string) => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
open.mockClear();
handleAction(
document.createElement("div"),
hass,
{ tap_action: { action: "url", url_path: url } },
"tap"
);
return open.mock.calls[0]?.[0];
};
afterEach(() => {
vi.restoreAllMocks();
});
describe("handleAction url", () => {
it("opens a configured URL", () => {
expect(openUrl("https://example.com/page")).toEqual(
"https://example.com/page"
);
expect(openUrl("mailto:someone@example.com")).toEqual(
"mailto:someone@example.com"
);
});
/* eslint-disable no-script-url */
it("does not open a URL that runs script", () => {
expect(openUrl("javascript:alert(1)")).toEqual("about:blank");
expect(openUrl("JaVaScRiPt:alert(1)")).toEqual("about:blank");
expect(openUrl("data:text/html,<script>alert(1)</script>")).toEqual(
"about:blank"
);
});
/* eslint-enable no-script-url */
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import "../../../src/panels/lovelace/cards/hui-iframe-card";
import "../../../src/panels/lovelace/special-rows/hui-weblink-row";
/* eslint-disable no-script-url */
const UNSAFE_URLS = [
"javascript:alert(1)",
"JaVaScRiPt:alert(1)",
"java\tscript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox(1)",
];
/* eslint-enable no-script-url */
const SAFE_URLS = [
"https://example.com/page",
"http://192.168.1.5:8080/",
"mailto:someone@example.com",
"/local/page.html",
// Forms that sanitizing rewrites, so they must not be compared to the input
"http://example.com",
"https://example.com/foo bar",
"https://Example.com/Path",
];
describe("hui-weblink-row config", () => {
const row = () => document.createElement("hui-weblink-row") as any;
it.each(SAFE_URLS)("accepts %s", (url) => {
expect(() => row().setConfig({ url })).not.toThrow();
});
it.each(UNSAFE_URLS)("rejects %s", (url) => {
expect(() => row().setConfig({ url })).toThrow("Invalid URL");
});
});
describe("hui-iframe-card config", () => {
const card = () => document.createElement("hui-iframe-card") as any;
it.each(SAFE_URLS)("accepts %s", (url) => {
expect(() => card().setConfig({ type: "iframe", url })).not.toThrow();
});
it.each(UNSAFE_URLS)("rejects %s", (url) => {
expect(() => card().setConfig({ type: "iframe", url })).toThrow(
"Invalid URL"
);
});
});
+7 -1
View File
@@ -1,5 +1,11 @@
global.window = (global.window ?? {}) as any;
global.navigator = (global.navigator ?? {}) as any;
if (!global.navigator) {
Object.defineProperty(global, "navigator", {
value: {},
configurable: true,
writable: true,
});
}
global.__DEMO__ = false;
global.__DEV__ = false;
+126 -137
View File
@@ -27,43 +27,28 @@ __metadata:
languageName: node
linkType: hard
"@asamuzakjp/css-color@npm:^5.1.11":
version: 5.1.11
resolution: "@asamuzakjp/css-color@npm:5.1.11"
"@asamuzakjp/css-color@npm:^6.0.5":
version: 6.0.5
resolution: "@asamuzakjp/css-color@npm:6.0.5"
dependencies:
"@asamuzakjp/generational-cache": "npm:^1.0.1"
"@csstools/css-calc": "npm:^3.2.0"
"@csstools/css-color-parser": "npm:^4.1.0"
"@csstools/css-calc": "npm:^3.2.1"
"@csstools/css-color-parser": "npm:^4.1.9"
"@csstools/css-parser-algorithms": "npm:^4.0.0"
"@csstools/css-tokenizer": "npm:^4.0.0"
checksum: 10/2e337cc94b5a3f9741a27f92b4e4b7dc467a76b1dcf66c40e71808fed71695f10c8cf07c8b13313cbb637154314ca1d8626bb9a045fe94b404b242a390cf3bd3
lru-cache: "npm:^11.5.2"
checksum: 10/bd88a9a1d00711f5f63c4288a82489c33e28de43dee37f418cd30a42517d7b7e2040dc79fc223ececf290e7b3f12bbd2dd8ce7450f73b03bf0613df449b9563f
languageName: node
linkType: hard
"@asamuzakjp/dom-selector@npm:^7.1.1":
version: 7.1.1
resolution: "@asamuzakjp/dom-selector@npm:7.1.1"
"@asamuzakjp/dom-selector@npm:^8.2.5":
version: 8.3.0
resolution: "@asamuzakjp/dom-selector@npm:8.3.0"
dependencies:
"@asamuzakjp/generational-cache": "npm:^1.0.1"
"@asamuzakjp/nwsapi": "npm:^2.3.9"
bidi-js: "npm:^1.0.3"
css-tree: "npm:^3.2.1"
is-potential-custom-element-name: "npm:^1.0.1"
checksum: 10/49a065a64db5f53a3008c231d09606e4b67f509fa20148a67419451c2dc91a421202ed17bfc4bc679ad2f0432d7260720d602c1d5c9c5e165931fff5199c3f12
languageName: node
linkType: hard
"@asamuzakjp/generational-cache@npm:^1.0.1":
version: 1.0.1
resolution: "@asamuzakjp/generational-cache@npm:1.0.1"
checksum: 10/e1cf3f1916a334c6153f624982f0eb3d50fa3048435ea5c5b0f441f8f1ab74a0fe992dac214b612d22c0acafad3cd1a1f6b45d99c7b6e3b63cfdf7f6ca5fc144
languageName: node
linkType: hard
"@asamuzakjp/nwsapi@npm:^2.3.9":
version: 2.3.9
resolution: "@asamuzakjp/nwsapi@npm:2.3.9"
checksum: 10/95a6d1c102e1117fe818da087fcc5b914d23e0699855991bae50b891435dd1945ad7d384198f8bcf616207fd85b7ec32e3db6b96e9309d84c6903b8dc4151e34
lru-cache: "npm:^11.5.2"
checksum: 10/458632671954613e1bd653c9c9d2e8ded44701f19ca8a9641ec3fa0df9eb60089769e0c98dd7991afd53b86e40f8a720024653f451af71ee644d6cf3241c9ec0
languageName: node
linkType: hard
@@ -2454,15 +2439,15 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/view@npm:6.43.6, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.37.0, @codemirror/view@npm:^6.42.0":
version: 6.43.6
resolution: "@codemirror/view@npm:6.43.6"
"@codemirror/view@npm:6.43.7, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.37.0, @codemirror/view@npm:^6.42.0":
version: 6.43.7
resolution: "@codemirror/view@npm:6.43.7"
dependencies:
"@codemirror/state": "npm:^6.7.0"
crelt: "npm:^1.0.6"
style-mod: "npm:^4.1.0"
w3c-keyname: "npm:^2.2.4"
checksum: 10/be058e86523d5770921c6bad7963eb5ef0a7db340922db93043394e1e93d1f3125611a3c2dbf143f1ab69b426038cd2034ffa7484d4b55d1ac909f2ceed2ee06
checksum: 10/dbc7c4e9162099dae15be1107e831c955c778fa224e24f4fd02d6eb9e75bc6c6d636e3e9fce3dc5852576c392c5793f4f6c5838f932a9c9e77f32a4ca66f62bf
languageName: node
linkType: hard
@@ -2482,26 +2467,26 @@ __metadata:
languageName: node
linkType: hard
"@csstools/css-calc@npm:^3.2.0, @csstools/css-calc@npm:^3.2.1":
version: 3.2.1
resolution: "@csstools/css-calc@npm:3.2.1"
"@csstools/css-calc@npm:^3.2.1, @csstools/css-calc@npm:^3.3.0":
version: 3.3.0
resolution: "@csstools/css-calc@npm:3.3.0"
peerDependencies:
"@csstools/css-parser-algorithms": ^4.0.0
"@csstools/css-tokenizer": ^4.0.0
checksum: 10/39042a9382cbd7c4fa241c1a6c10d64c23fa7653970fc11df532e9bc42a366a8b50c3ac3036bd5f2a77b94144e7f804f19d2b52800ad2f48bd9a3840377165d8
checksum: 10/46dde1c26c8a62d7b849a616a521cb97abc2d6c8c480f7be6f14c70fe60689a2a58c1f5f905aae237250ad27172968e10c70f6f83864c7dc48d8f8921f55f6a9
languageName: node
linkType: hard
"@csstools/css-color-parser@npm:^4.1.0":
version: 4.1.9
resolution: "@csstools/css-color-parser@npm:4.1.9"
"@csstools/css-color-parser@npm:^4.1.9":
version: 4.1.10
resolution: "@csstools/css-color-parser@npm:4.1.10"
dependencies:
"@csstools/color-helpers": "npm:^6.1.0"
"@csstools/css-calc": "npm:^3.2.1"
"@csstools/css-calc": "npm:^3.3.0"
peerDependencies:
"@csstools/css-parser-algorithms": ^4.0.0
"@csstools/css-tokenizer": ^4.0.0
checksum: 10/6369b601bcd3a8ce58dbcdc389a732b754c8f3fc35421b37169f6d4b07c7261f7b8c23e7d04e457da5e5e0bb082e680acb137d8c86fa1f7d6ff14ea873bf02a2
checksum: 10/92f5f590617a2cc9ff358b2d79716cbe15dd4cc7537cdc45ffc55878282677a9ef6c8bfb215a7dc0731f302c32bf1b0d1b7fc3c550147fd8a6d85f7c3cfb0cdd
languageName: node
linkType: hard
@@ -2514,15 +2499,15 @@ __metadata:
languageName: node
linkType: hard
"@csstools/css-syntax-patches-for-csstree@npm:^1.1.3":
version: 1.1.6
resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.1.6"
"@csstools/css-syntax-patches-for-csstree@npm:^1.1.6":
version: 1.1.7
resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.1.7"
peerDependencies:
css-tree: ^3.2.1
peerDependenciesMeta:
css-tree:
optional: true
checksum: 10/8747268b42f1afbe450d38b7f388a4983c810883f8c0716fa24f5b1eef0f9cbaa3a0f4ea72d1e5cbd015dc4fd5af9cc96ade42792912ed2dcc1d4054de102163
checksum: 10/ba372ab41c351509d47e77f58eb56112fe6fd42e104b70c9c9c797faf4fbb3e837a5df5c9d7e3464b04607ee42e91701e34f935aeec8abb94f24be67cfa1b707
languageName: node
linkType: hard
@@ -2726,7 +2711,7 @@ __metadata:
languageName: node
linkType: hard
"@exodus/bytes@npm:^1.11.0, @exodus/bytes@npm:^1.15.0, @exodus/bytes@npm:^1.6.0":
"@exodus/bytes@npm:^1.11.0, @exodus/bytes@npm:^1.15.1, @exodus/bytes@npm:^1.6.0":
version: 1.15.1
resolution: "@exodus/bytes@npm:1.15.1"
peerDependencies:
@@ -4755,65 +4740,65 @@ __metadata:
languageName: node
linkType: hard
"@rspack/binding-darwin-arm64@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-darwin-arm64@npm:2.1.5"
"@rspack/binding-darwin-arm64@npm:2.1.6":
version: 2.1.6
resolution: "@rspack/binding-darwin-arm64@npm:2.1.6"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-darwin-x64@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-darwin-x64@npm:2.1.5"
"@rspack/binding-darwin-x64@npm:2.1.6":
version: 2.1.6
resolution: "@rspack/binding-darwin-x64@npm:2.1.6"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-gnu@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.5"
"@rspack/binding-linux-arm64-gnu@npm:2.1.6":
version: 2.1.6
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.6"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-musl@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.5"
"@rspack/binding-linux-arm64-musl@npm:2.1.6":
version: 2.1.6
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.6"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-linux-riscv64-gnu@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.5"
"@rspack/binding-linux-riscv64-gnu@npm:2.1.6":
version: 2.1.6
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.6"
conditions: os=linux & cpu=riscv64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-riscv64-musl@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.5"
"@rspack/binding-linux-riscv64-musl@npm:2.1.6":
version: 2.1.6
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.6"
conditions: os=linux & cpu=riscv64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-linux-x64-gnu@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.5"
"@rspack/binding-linux-x64-gnu@npm:2.1.6":
version: 2.1.6
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.6"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-x64-musl@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.5"
"@rspack/binding-linux-x64-musl@npm:2.1.6":
version: 2.1.6
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.6"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-wasm32-wasi@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.5"
"@rspack/binding-wasm32-wasi@npm:2.1.6":
version: 2.1.6
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.6"
dependencies:
"@emnapi/core": "npm:1.11.2"
"@emnapi/runtime": "npm:1.11.2"
@@ -4822,43 +4807,43 @@ __metadata:
languageName: node
linkType: hard
"@rspack/binding-win32-arm64-msvc@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.5"
"@rspack/binding-win32-arm64-msvc@npm:2.1.6":
version: 2.1.6
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.6"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-win32-ia32-msvc@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.5"
"@rspack/binding-win32-ia32-msvc@npm:2.1.6":
version: 2.1.6
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.6"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@rspack/binding-win32-x64-msvc@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.5"
"@rspack/binding-win32-x64-msvc@npm:2.1.6":
version: 2.1.6
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.6"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@rspack/binding@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding@npm:2.1.5"
"@rspack/binding@npm:2.1.6":
version: 2.1.6
resolution: "@rspack/binding@npm:2.1.6"
dependencies:
"@rspack/binding-darwin-arm64": "npm:2.1.5"
"@rspack/binding-darwin-x64": "npm:2.1.5"
"@rspack/binding-linux-arm64-gnu": "npm:2.1.5"
"@rspack/binding-linux-arm64-musl": "npm:2.1.5"
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.5"
"@rspack/binding-linux-riscv64-musl": "npm:2.1.5"
"@rspack/binding-linux-x64-gnu": "npm:2.1.5"
"@rspack/binding-linux-x64-musl": "npm:2.1.5"
"@rspack/binding-wasm32-wasi": "npm:2.1.5"
"@rspack/binding-win32-arm64-msvc": "npm:2.1.5"
"@rspack/binding-win32-ia32-msvc": "npm:2.1.5"
"@rspack/binding-win32-x64-msvc": "npm:2.1.5"
"@rspack/binding-darwin-arm64": "npm:2.1.6"
"@rspack/binding-darwin-x64": "npm:2.1.6"
"@rspack/binding-linux-arm64-gnu": "npm:2.1.6"
"@rspack/binding-linux-arm64-musl": "npm:2.1.6"
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.6"
"@rspack/binding-linux-riscv64-musl": "npm:2.1.6"
"@rspack/binding-linux-x64-gnu": "npm:2.1.6"
"@rspack/binding-linux-x64-musl": "npm:2.1.6"
"@rspack/binding-wasm32-wasi": "npm:2.1.6"
"@rspack/binding-win32-arm64-msvc": "npm:2.1.6"
"@rspack/binding-win32-ia32-msvc": "npm:2.1.6"
"@rspack/binding-win32-x64-msvc": "npm:2.1.6"
dependenciesMeta:
"@rspack/binding-darwin-arm64":
optional: true
@@ -4884,15 +4869,15 @@ __metadata:
optional: true
"@rspack/binding-win32-x64-msvc":
optional: true
checksum: 10/d1054348d3ba734485f977d574c6311311707adfcc5e53f03c8dbbbdc5778cff9b815e1d675992e2832241606d4cce7678acc3999b259139120b4ec30751f1e1
checksum: 10/0d164b7425f448ed6b8b81e687f4a82377289476cee41553ced9532e211d216fa13429b9125f3e3f025e391709bf752983c16eb4549341a21cbd0c61510afbeb
languageName: node
linkType: hard
"@rspack/core@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/core@npm:2.1.5"
"@rspack/core@npm:2.1.6":
version: 2.1.6
resolution: "@rspack/core@npm:2.1.6"
dependencies:
"@rspack/binding": "npm:2.1.5"
"@rspack/binding": "npm:2.1.6"
peerDependencies:
"@module-federation/runtime-tools": ^0.24.1 || ^2.0.0
"@swc/helpers": ^0.5.23
@@ -4901,7 +4886,7 @@ __metadata:
optional: true
"@swc/helpers":
optional: true
checksum: 10/714064de701211724f7d859bc269d357fa2ad2f7ea0ac34d1e4e48b534981cf3961bd723c0659b9c093330f04e01e16a0c5587d6a18301727e27d717f764ded7
checksum: 10/334f8e13aa3540b7320d3186250fc4e2f128711f765d2073274188f23c692d4262e7cbb88c10571626b55e480186c474bdbcb564e7fa97df803479554de855b4
languageName: node
linkType: hard
@@ -9675,10 +9660,10 @@ __metadata:
languageName: node
linkType: hard
"globals@npm:17.7.0":
version: 17.7.0
resolution: "globals@npm:17.7.0"
checksum: 10/79304ccc4d2ca167ea15bdb25da346aa34ce3847b18fbd6c3cad182e152505305db3c9722fd5e292c62f6db97a8fa06e0c110a1e7703d7325498e5351d08cab4
"globals@npm:17.8.0":
version: 17.8.0
resolution: "globals@npm:17.8.0"
checksum: 10/b7b854b2052d2608d1878884bf730c027e17b9e2d194834f48c3280a7f0005b06d5f51dba362d3149cc05eb90d36d990e5c996728ecd3585a43dc3b4a286ed85
languageName: node
linkType: hard
@@ -9915,7 +9900,7 @@ __metadata:
"@codemirror/lint": "npm:6.9.7"
"@codemirror/search": "npm:6.7.1"
"@codemirror/state": "npm:6.7.1"
"@codemirror/view": "npm:6.43.6"
"@codemirror/view": "npm:6.43.7"
"@date-fns/tz": "npm:1.5.0"
"@egjs/hammerjs": "npm:2.0.17"
"@eslint/js": "npm:10.0.1"
@@ -9955,7 +9940,7 @@ __metadata:
"@playwright/test": "npm:1.62.0"
"@replit/codemirror-indentation-markers": "npm:6.5.3"
"@rsdoctor/rspack-plugin": "npm:1.6.1"
"@rspack/core": "npm:2.1.5"
"@rspack/core": "npm:2.1.6"
"@rspack/dev-server": "npm:2.1.0"
"@swc/helpers": "npm:0.5.23"
"@thomasloven/round-slider": "npm:0.6.0"
@@ -10011,7 +9996,7 @@ __metadata:
fuse.js: "npm:7.5.0"
generate-license-file: "npm:4.2.1"
glob: "npm:13.0.6"
globals: "npm:17.7.0"
globals: "npm:17.8.0"
gulp: "npm:5.0.1"
gulp-brotli: "npm:3.0.0"
gulp-json-transform: "npm:0.5.0"
@@ -10024,7 +10009,7 @@ __metadata:
idb-keyval: "npm:6.3.0"
intl-messageformat: "npm:11.2.12"
js-yaml: "npm:5.2.2"
jsdom: "npm:29.1.1"
jsdom: "npm:30.0.0"
jszip: "npm:3.10.1"
leaflet: "npm:1.9.4"
leaflet-draw: "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
@@ -10979,37 +10964,37 @@ __metadata:
languageName: node
linkType: hard
"jsdom@npm:29.1.1":
version: 29.1.1
resolution: "jsdom@npm:29.1.1"
"jsdom@npm:30.0.0":
version: 30.0.0
resolution: "jsdom@npm:30.0.0"
dependencies:
"@asamuzakjp/css-color": "npm:^5.1.11"
"@asamuzakjp/dom-selector": "npm:^7.1.1"
"@asamuzakjp/css-color": "npm:^6.0.5"
"@asamuzakjp/dom-selector": "npm:^8.2.5"
"@bramus/specificity": "npm:^2.4.2"
"@csstools/css-syntax-patches-for-csstree": "npm:^1.1.3"
"@exodus/bytes": "npm:^1.15.0"
"@csstools/css-syntax-patches-for-csstree": "npm:^1.1.6"
"@exodus/bytes": "npm:^1.15.1"
css-tree: "npm:^3.2.1"
data-urls: "npm:^7.0.0"
decimal.js: "npm:^10.6.0"
html-encoding-sniffer: "npm:^6.0.0"
is-potential-custom-element-name: "npm:^1.0.1"
lru-cache: "npm:^11.3.5"
lru-cache: "npm:^11.5.2"
parse5: "npm:^8.0.1"
saxes: "npm:^6.0.0"
symbol-tree: "npm:^3.2.4"
tough-cookie: "npm:^6.0.1"
undici: "npm:^7.25.0"
tough-cookie: "npm:^6.0.2"
undici: "npm:^8.7.0"
w3c-xmlserializer: "npm:^5.0.0"
webidl-conversions: "npm:^8.0.1"
whatwg-mimetype: "npm:^5.0.0"
whatwg-url: "npm:^16.0.1"
whatwg-url: "npm:^17.1.0"
xml-name-validator: "npm:^5.0.0"
peerDependencies:
canvas: ^3.0.0
canvas: ^3.2.3
peerDependenciesMeta:
canvas:
optional: true
checksum: 10/344aed7f91839b6c7d1b40778c5542d6ded7d42d88e1b787e10bf12d4ccd65464a5f23f774eb84350885c75a48efc99f6972adbb94dffe324a1b065d3650843c
checksum: 10/dc73c071e67224465018733525047d05772667bd8e00a3703a6f6515238c027b6ca768bf86874a53d512fd0bb962d72cc6a6c31c1bc38e52318f9d412ad0bb90
languageName: node
linkType: hard
@@ -11595,7 +11580,7 @@ __metadata:
languageName: node
linkType: hard
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1, lru-cache@npm:^11.3.5":
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1, lru-cache@npm:^11.5.2":
version: 11.5.2
resolution: "lru-cache@npm:11.5.2"
checksum: 10/122789c66605ddf29df58ff279e9e2c9499499d0b80b895ec524496f14bcde045d1582e3e38008f3457226d98067556719573b352119d6be8bdb1f8849f85681
@@ -14745,7 +14730,7 @@ __metadata:
languageName: node
linkType: hard
"tough-cookie@npm:^6.0.1":
"tough-cookie@npm:^6.0.2":
version: 6.0.2
resolution: "tough-cookie@npm:6.0.2"
dependencies:
@@ -15071,17 +15056,10 @@ __metadata:
languageName: node
linkType: hard
"undici@npm:^7.25.0":
version: 7.28.0
resolution: "undici@npm:7.28.0"
checksum: 10/154423b280d623278a61decb437f8a7e581fb18b8c95556ef956b32a58cd668eadbb812d28e20678cb2dc545a566f35a3afc0962307ca801da30f4741117986d
languageName: node
linkType: hard
"undici@npm:^8.4.1":
version: 8.7.0
resolution: "undici@npm:8.7.0"
checksum: 10/e9644903bda97f825228b4c7bee95b3586609f94df685b598c32bdaf3ac795928cbd25b0ed2690c3ea0b5abd324f5a9641b63b6c81c9b19f3ef4d5b3e402a48c
"undici@npm:^8.4.1, undici@npm:^8.7.0":
version: 8.9.0
resolution: "undici@npm:8.9.0"
checksum: 10/dfad3e233087eafdf1d361acd17c4d45a9590d5db9400458f1c03f47d8f1ef68647e2109ebb982c862e50c227093abd2d5f44b154904fc0b3d22576b6e95cfe7
languageName: node
linkType: hard
@@ -15710,7 +15688,7 @@ __metadata:
languageName: node
linkType: hard
"whatwg-url@npm:^16.0.0, whatwg-url@npm:^16.0.1":
"whatwg-url@npm:^16.0.0":
version: 16.0.1
resolution: "whatwg-url@npm:16.0.1"
dependencies:
@@ -15721,6 +15699,17 @@ __metadata:
languageName: node
linkType: hard
"whatwg-url@npm:^17.1.0":
version: 17.1.0
resolution: "whatwg-url@npm:17.1.0"
dependencies:
"@exodus/bytes": "npm:^1.15.1"
tr46: "npm:^6.0.0"
webidl-conversions: "npm:^8.0.1"
checksum: 10/52052ba12a63e7665ee49d84d251a3d4776be4777fb259155ea3ca9bc49cdbbd18cbb5fce50f187e8450ce81d7abbbc4c39cfb15207c6d11d5c2539f9b87d426
languageName: node
linkType: hard
"whatwg-url@npm:^5.0.0":
version: 5.0.0
resolution: "whatwg-url@npm:5.0.0"