mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-31 03:18:22 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d01f7d98a5 | |||
| 6df6b556bd | |||
| f5c6420fbc | |||
| 71ec76185f | |||
| f08ab2f331 | |||
| 77b0b9b5b8 | |||
| 4fcc1adf3a | |||
| 9699851c26 | |||
| a318593c9f | |||
| 749dd180ae | |||
| 247780e4c8 |
@@ -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,24 @@ 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. It prevents another foreground or background build from starting while one is using the shared generated files under `build/` and `hass_frontend/`.
|
||||
|
||||
## 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 +60,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]`. 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// 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,
|
||||
processStartTime,
|
||||
readProcessRecord,
|
||||
releaseProcessRecord,
|
||||
runCli,
|
||||
spawnDetachedToLog,
|
||||
spawnForeground,
|
||||
terminateProcess,
|
||||
waitFor,
|
||||
writeProcessRecord,
|
||||
} from "./managed-process.mjs";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
".."
|
||||
);
|
||||
const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp");
|
||||
const stateDir = path.join(repoRoot, "node_modules", ".cache", "ha-build");
|
||||
const logFile = path.join(stateDir, "build.log");
|
||||
const lockFile = path.join(
|
||||
repoRoot,
|
||||
"node_modules",
|
||||
".cache",
|
||||
"ha-generated-output.lock"
|
||||
);
|
||||
|
||||
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 readBuild = () => readProcessRecord(lockFile);
|
||||
|
||||
const releaseBuild = (token) => releaseProcessRecord(lockFile, token);
|
||||
|
||||
const acquireBuild = (modern, foreground) => {
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
|
||||
const result = acquireProcessRecord(lockFile, {
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
processGroup: false,
|
||||
foreground,
|
||||
kind: "build",
|
||||
modern,
|
||||
starting: true,
|
||||
token,
|
||||
});
|
||||
return result.acquired ? { token } : { 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 === "dev") {
|
||||
const command = existing.suite === "app-serve" ? "dev:serve" : "dev";
|
||||
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 = acquireBuild(modern, true);
|
||||
if (!lock.token) {
|
||||
reportExisting(lock.existing);
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
return await spawnForeground({
|
||||
cmd: gulpBin,
|
||||
args: [taskFor(modern)],
|
||||
cwd: repoRoot,
|
||||
processGroup: true,
|
||||
onSpawn: (child) => updateBuild(lock.token, child, true),
|
||||
});
|
||||
} finally {
|
||||
releaseBuild(lock.token);
|
||||
}
|
||||
};
|
||||
|
||||
const runBackground = async (modern) => {
|
||||
const lock = acquireBuild(modern, false);
|
||||
if (!lock.token) {
|
||||
reportExisting(lock.existing);
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
const child = await spawnDetachedToLog({
|
||||
cmd: gulpBin,
|
||||
args: [taskFor(modern)],
|
||||
cwd: repoRoot,
|
||||
logFile,
|
||||
});
|
||||
updateBuild(lock.token, child, true);
|
||||
process.stdout.write(
|
||||
`Started ${modern ? "modern " : ""}frontend build (pid ${child.pid})\n` +
|
||||
hints()
|
||||
);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
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") ||
|
||||
(args.modern && !["foreground", "background"].includes(args.mode))
|
||||
) {
|
||||
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);
|
||||
+338
-255
@@ -20,10 +20,27 @@
|
||||
// no health endpoint, and plain yarn dev has no port at all, so these
|
||||
// track a pidfile and treat the first "Build done" log line as ready.
|
||||
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
LIFECYCLE_MODE_FLAGS,
|
||||
acquireProcessRecord,
|
||||
isProcessRecordAlive,
|
||||
outputLog,
|
||||
processStartTime,
|
||||
readProcessRecord,
|
||||
releaseProcessRecord,
|
||||
removeProcessRecord,
|
||||
runCli,
|
||||
sleep,
|
||||
spawnDetachedToLog,
|
||||
spawnForeground,
|
||||
terminateProcess,
|
||||
waitFor,
|
||||
writeProcessRecord,
|
||||
} from "./managed-process.mjs";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
@@ -36,48 +53,74 @@ const developAndServeScript = path.join(
|
||||
"develop_and_serve"
|
||||
);
|
||||
const logDir = path.join(repoRoot, "node_modules", ".cache", "ha-dev-server");
|
||||
const outputLockFile = path.join(
|
||||
repoRoot,
|
||||
"node_modules",
|
||||
".cache",
|
||||
"ha-generated-output.lock"
|
||||
);
|
||||
|
||||
// Each suite names its yarn alias (for hints), a liveness model, and how to
|
||||
// spawn it. health suites carry a fixed port; process suites carry the log line
|
||||
// that means "ready" and, for app-serve, forward extra args to the script.
|
||||
const SUITES = {
|
||||
"e2e-app": {
|
||||
alias: "test:e2e:app:dev",
|
||||
liveness: "health",
|
||||
port: 8095,
|
||||
spawn: { cmd: gulpBin, args: ["develop-e2e-test-app"] },
|
||||
},
|
||||
demo: {
|
||||
alias: "dev:demo",
|
||||
liveness: "health",
|
||||
port: 8090,
|
||||
spawn: { cmd: gulpBin, args: ["develop-demo"] },
|
||||
},
|
||||
gallery: {
|
||||
alias: "dev:gallery",
|
||||
liveness: "health",
|
||||
port: 8100,
|
||||
spawn: { cmd: gulpBin, args: ["develop-gallery"] },
|
||||
},
|
||||
app: {
|
||||
alias: "dev",
|
||||
liveness: "process",
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: gulpBin, args: ["develop-app"] },
|
||||
},
|
||||
"app-serve": {
|
||||
alias: "dev:serve",
|
||||
liveness: "process",
|
||||
acceptsArgs: true,
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: developAndServeScript, args: [] },
|
||||
},
|
||||
};
|
||||
const SUITES = new Map([
|
||||
[
|
||||
"e2e-app",
|
||||
{
|
||||
alias: "test:e2e:app:dev",
|
||||
liveness: "health",
|
||||
port: 8095,
|
||||
spawn: { cmd: gulpBin, args: ["develop-e2e-test-app"] },
|
||||
},
|
||||
],
|
||||
[
|
||||
"demo",
|
||||
{
|
||||
alias: "dev:demo",
|
||||
liveness: "health",
|
||||
port: 8090,
|
||||
spawn: { cmd: gulpBin, args: ["develop-demo"] },
|
||||
},
|
||||
],
|
||||
[
|
||||
"gallery",
|
||||
{
|
||||
alias: "dev:gallery",
|
||||
liveness: "health",
|
||||
port: 8100,
|
||||
spawn: { cmd: gulpBin, args: ["develop-gallery"] },
|
||||
},
|
||||
],
|
||||
[
|
||||
"app",
|
||||
{
|
||||
alias: "dev",
|
||||
liveness: "process",
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: gulpBin, args: ["develop-app"] },
|
||||
processKey: "app",
|
||||
},
|
||||
],
|
||||
[
|
||||
"app-serve",
|
||||
{
|
||||
alias: "dev:serve",
|
||||
liveness: "process",
|
||||
acceptsArgs: true,
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: developAndServeScript, args: [] },
|
||||
processKey: "app",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
// Cover a cold build on a slow machine before the server starts listening.
|
||||
// Override with HA_DEV_SERVER_TIMEOUT (seconds).
|
||||
const readyTimeoutSeconds = Number(process.env.HA_DEV_SERVER_TIMEOUT || "180");
|
||||
const READY_TIMEOUT_MS =
|
||||
Number(process.env.HA_DEV_SERVER_TIMEOUT || "180") * 1000;
|
||||
Number.isFinite(readyTimeoutSeconds) && readyTimeoutSeconds > 0
|
||||
? readyTimeoutSeconds * 1000
|
||||
: 180_000;
|
||||
|
||||
// Detect a coding agent from a small set of environment markers set by common
|
||||
// agent CLIs (env-only; no process-ancestry detection).
|
||||
@@ -104,7 +147,7 @@ const detectAgent = () => {
|
||||
};
|
||||
|
||||
const usage = () => {
|
||||
const suites = Object.keys(SUITES).join("|");
|
||||
const suites = [...SUITES.keys()].join("|");
|
||||
process.stderr.write(
|
||||
`Usage: node build-scripts/dev-server.mjs --suite <${suites}> ` +
|
||||
`[--background | --status | --stop | --logs [--follow]]\n`
|
||||
@@ -115,6 +158,7 @@ const parseArgs = (argv) => {
|
||||
const args = {
|
||||
mode: "foreground",
|
||||
follow: false,
|
||||
modes: [],
|
||||
suite: undefined,
|
||||
passthrough: [],
|
||||
};
|
||||
@@ -124,39 +168,99 @@ const parseArgs = (argv) => {
|
||||
case "--suite":
|
||||
args.suite = argv[++i];
|
||||
break;
|
||||
case "--background":
|
||||
args.mode = "background";
|
||||
break;
|
||||
case "--status":
|
||||
args.mode = "status";
|
||||
break;
|
||||
case "--stop":
|
||||
args.mode = "stop";
|
||||
break;
|
||||
case "--logs":
|
||||
args.mode = "logs";
|
||||
break;
|
||||
case "--follow":
|
||||
args.follow = true;
|
||||
break;
|
||||
default:
|
||||
// Anything unrecognised is forwarded to the underlying script.
|
||||
args.passthrough.push(arg);
|
||||
if (LIFECYCLE_MODE_FLAGS.has(arg)) {
|
||||
args.mode = LIFECYCLE_MODE_FLAGS.get(arg);
|
||||
args.modes.push(arg);
|
||||
} else {
|
||||
// Anything unrecognised is forwarded to the underlying script.
|
||||
args.passthrough.push(arg);
|
||||
}
|
||||
}
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
const sleep = (ms) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
const logFileFor = (suite) => path.join(logDir, `${suite}.log`);
|
||||
const pidFileFor = (suite) => path.join(logDir, `${suite}.pid`);
|
||||
const pidFileFor = (suite) =>
|
||||
path.join(logDir, `${SUITES.get(suite).processKey ?? suite}.pid`);
|
||||
const removePidFileIf = (suite, matches) => {
|
||||
const existing = readProcessRecord(pidFileFor(suite));
|
||||
if (!existing) {
|
||||
removeProcessRecord(pidFileFor(suite));
|
||||
return true;
|
||||
}
|
||||
if (matches(existing)) {
|
||||
releaseProcessRecord(outputLockFile, existing.token, () => {
|
||||
if (readProcessRecord(pidFileFor(suite))?.token === existing.token) {
|
||||
removeProcessRecord(pidFileFor(suite));
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const acquireProcessSuite = (suite) => {
|
||||
const pidFile = pidFileFor(suite);
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
|
||||
const record = {
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
kind: "dev",
|
||||
suite,
|
||||
starting: true,
|
||||
token,
|
||||
};
|
||||
const result = acquireProcessRecord(outputLockFile, record);
|
||||
if (!result.acquired) {
|
||||
return { existing: result.existing };
|
||||
}
|
||||
try {
|
||||
writeProcessRecord(pidFile, record);
|
||||
} catch (err) {
|
||||
releaseProcessRecord(outputLockFile, token);
|
||||
throw err;
|
||||
}
|
||||
return { token };
|
||||
};
|
||||
|
||||
const updateProcessSuite = (suite, token, child) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing?.token !== token) {
|
||||
throw Error(
|
||||
`Dev server (${suite}) process ownership was lost during startup.`
|
||||
);
|
||||
}
|
||||
const record = {
|
||||
...existing,
|
||||
pid: child.pid,
|
||||
startTime: processStartTime(child.pid),
|
||||
starting: false,
|
||||
};
|
||||
writePidFile(suite, record);
|
||||
const outputOwner = readProcessRecord(outputLockFile);
|
||||
if (outputOwner?.token !== token) {
|
||||
throw Error(
|
||||
`Dev server (${suite}) output ownership was lost during startup.`
|
||||
);
|
||||
}
|
||||
writeProcessRecord(outputLockFile, record);
|
||||
};
|
||||
|
||||
const releaseProcessSuite = (suite, token) => {
|
||||
releaseProcessRecord(outputLockFile, token, () => {
|
||||
if (readPidFile(suite)?.token === token) {
|
||||
removeProcessRecord(pidFileFor(suite));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const hints = (suite) => {
|
||||
const alias = `yarn ${SUITES[suite].alias}`;
|
||||
const alias = `yarn ${SUITES.get(suite).alias}`;
|
||||
return (
|
||||
` Stop: ${alias} --stop\n` +
|
||||
` Status: ${alias} --status\n` +
|
||||
@@ -164,46 +268,26 @@ const hints = (suite) => {
|
||||
);
|
||||
};
|
||||
|
||||
const reportProcessConflict = (suite, existing) => {
|
||||
if (existing?.kind === "build") {
|
||||
process.stdout.write(
|
||||
`Frontend build already running${existing.pid ? ` (pid ${existing.pid})` : ""}. ` +
|
||||
"Stop it with yarn build --stop.\n"
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(
|
||||
`Dev server (${existing?.suite ?? suite}) already running` +
|
||||
`${urlSuffix(existing?.port)} ` +
|
||||
`${existing?.pid ? `(pid ${existing.pid})` : ""}\n` +
|
||||
hints(existing?.suite ?? suite)
|
||||
);
|
||||
};
|
||||
|
||||
// --- shared spawning and lifecycle ------------------------------------------
|
||||
|
||||
// Signal the whole process group (the background server is its group leader),
|
||||
// falling back to the bare pid if that is not permitted.
|
||||
const killProcessTree = (pid, sig) => {
|
||||
try {
|
||||
process.kill(-pid, sig);
|
||||
} catch {
|
||||
try {
|
||||
process.kill(pid, sig);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const urlSuffix = (port) => (port ? ` at http://localhost:${port}` : "");
|
||||
|
||||
// Run a server in the foreground, inheriting stdio; resolve with its exit code.
|
||||
const spawnInherit = (cmd, args) =>
|
||||
new Promise((resolve) => {
|
||||
const child = spawn(cmd, args, { cwd: repoRoot, stdio: "inherit" });
|
||||
child.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
|
||||
// Spawn a detached server that writes stdout and stderr to the suite's log file.
|
||||
const spawnDetachedToLog = (suite, cmd, args) => {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
const logFile = logFileFor(suite);
|
||||
const fd = fs.openSync(logFile, "w");
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: repoRoot,
|
||||
detached: true,
|
||||
stdio: ["ignore", fd, fd],
|
||||
});
|
||||
fs.closeSync(fd);
|
||||
child.unref();
|
||||
return { child, logFile };
|
||||
};
|
||||
|
||||
// Poll until the server is ready, the child exits, or we time out. Prints the
|
||||
// progress dots and outcome; returns 0 when ready, 1 otherwise. onExit runs if
|
||||
// the child dies before it is ready (used to clear a stale pidfile).
|
||||
@@ -214,8 +298,7 @@ const awaitReady = async ({ suite, child, logFile, port, isReady, onExit }) => {
|
||||
});
|
||||
const deadline = Date.now() + READY_TIMEOUT_MS;
|
||||
process.stdout.write(`Starting ${suite} dev server`);
|
||||
/* eslint-disable no-await-in-loop -- poll until the server is ready */
|
||||
while (Date.now() < deadline) {
|
||||
const poll = async () => {
|
||||
if (childExited) {
|
||||
process.stdout.write("\n");
|
||||
process.stderr.write(
|
||||
@@ -232,38 +315,37 @@ const awaitReady = async ({ suite, child, logFile, port, isReady, onExit }) => {
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
return undefined;
|
||||
}
|
||||
process.stdout.write(".");
|
||||
await sleep(1000);
|
||||
return poll();
|
||||
};
|
||||
const result = await poll();
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
process.stdout.write("\n");
|
||||
process.stderr.write(
|
||||
`Dev server (${suite}) did not become ready within ${
|
||||
READY_TIMEOUT_MS / 1000
|
||||
}s. See ${logFile}\n`
|
||||
);
|
||||
const stopped = await terminateProcess({
|
||||
pid: child.pid,
|
||||
isStopped: () => childExited,
|
||||
});
|
||||
if (stopped) {
|
||||
onExit?.();
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
// Stop a running background server: SIGTERM, wait for it to go, then SIGKILL.
|
||||
// isStopped reports when it is gone; onStopped runs on success (pidfile cleanup).
|
||||
const terminate = async (suite, pid, isStopped, onStopped) => {
|
||||
killProcessTree(pid, "SIGTERM");
|
||||
const deadline = Date.now() + 10_000;
|
||||
/* eslint-disable no-await-in-loop -- poll until the server is gone */
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(300);
|
||||
if (await isStopped()) {
|
||||
onStopped?.();
|
||||
process.stdout.write(`Stopped dev server (${suite}) (pid ${pid}).\n`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
// Escalate if it is still up.
|
||||
killProcessTree(pid, "SIGKILL");
|
||||
await sleep(300);
|
||||
if (!(await isStopped())) {
|
||||
if (!(await terminateProcess({ pid, isStopped }))) {
|
||||
process.stderr.write(
|
||||
`Failed to stop dev server (${suite}) (pid ${pid}). Stop it manually.\n`
|
||||
);
|
||||
@@ -284,9 +366,11 @@ const terminate = async (suite, pid, isStopped, onStopped) => {
|
||||
const PROBE_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
||||
|
||||
const probe = async (port, timeoutMs = 1000) => {
|
||||
let sawResponse = false;
|
||||
/* eslint-disable no-await-in-loop -- probe localhost addresses in order, stopping at the first that answers */
|
||||
for (const host of PROBE_HOSTS) {
|
||||
const probeHost = async (index, sawResponse) => {
|
||||
const host = PROBE_HOSTS[index];
|
||||
if (!host) {
|
||||
return sawResponse ? { state: "foreign" } : { state: "free" };
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
@@ -305,9 +389,9 @@ const probe = async (port, timeoutMs = 1000) => {
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
return sawResponse ? { state: "foreign" } : { state: "free" };
|
||||
return probeHost(index + 1, sawResponse);
|
||||
};
|
||||
return probeHost(0, false);
|
||||
};
|
||||
|
||||
// Find the pid listening on a port via the first available tool (no state file).
|
||||
@@ -351,13 +435,23 @@ const runForegroundHealth = async (suite, cfg) => {
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (status.state === "ours") {
|
||||
process.stderr.write(
|
||||
`Port ${port} is serving the ${status.suite ?? "unknown"} dev server; not ${suite}.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if (status.state === "foreign") {
|
||||
process.stderr.write(
|
||||
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
return spawnInherit(cfg.spawn.cmd, cfg.spawn.args);
|
||||
return spawnForeground({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: cfg.spawn.args,
|
||||
cwd: repoRoot,
|
||||
});
|
||||
};
|
||||
|
||||
const runBackgroundHealth = async (suite, cfg) => {
|
||||
@@ -371,6 +465,12 @@ const runBackgroundHealth = async (suite, cfg) => {
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (preflight.state === "ours") {
|
||||
process.stderr.write(
|
||||
`Port ${port} is serving the ${preflight.suite ?? "unknown"} dev server; not ${suite}.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if (preflight.state === "foreign") {
|
||||
process.stderr.write(
|
||||
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
||||
@@ -378,11 +478,13 @@ const runBackgroundHealth = async (suite, cfg) => {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { child, logFile } = spawnDetachedToLog(
|
||||
suite,
|
||||
cfg.spawn.cmd,
|
||||
cfg.spawn.args
|
||||
);
|
||||
const logFile = logFileFor(suite);
|
||||
const child = await spawnDetachedToLog({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: cfg.spawn.args,
|
||||
cwd: repoRoot,
|
||||
logFile,
|
||||
});
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
@@ -443,42 +545,12 @@ const runStopHealth = async (suite, cfg) => {
|
||||
|
||||
// --- process liveness (pidfile + log-readiness) -----------------------------
|
||||
|
||||
const isAlive = (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";
|
||||
}
|
||||
};
|
||||
|
||||
const readPidFile = (suite) => {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(pidFileFor(suite), "utf8"));
|
||||
if (data && Number.isInteger(data.pid)) {
|
||||
return data;
|
||||
}
|
||||
} catch {
|
||||
// Missing or corrupt.
|
||||
}
|
||||
return undefined;
|
||||
return readProcessRecord(pidFileFor(suite));
|
||||
};
|
||||
|
||||
const writePidFile = (suite, data) => {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
fs.writeFileSync(pidFileFor(suite), JSON.stringify(data));
|
||||
};
|
||||
|
||||
const removePidFile = (suite) => {
|
||||
try {
|
||||
fs.rmSync(pidFileFor(suite));
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
writeProcessRecord(pidFileFor(suite), data);
|
||||
};
|
||||
|
||||
const logIsReady = (logFile, readyLog) => {
|
||||
@@ -492,11 +564,13 @@ const logIsReady = (logFile, readyLog) => {
|
||||
// app-serve serves on 8124 by default (8123 in a devcontainer), or whatever -p
|
||||
// the caller passed. Used only to show a URL; liveness comes from the pidfile.
|
||||
const resolveServePort = (passthrough) => {
|
||||
const i = passthrough.indexOf("-p");
|
||||
if (i !== -1) {
|
||||
const port = Number(passthrough[i + 1]);
|
||||
if (Number.isInteger(port) && port > 0) {
|
||||
return port;
|
||||
for (let i = passthrough.length - 1; i >= 0; i--) {
|
||||
const arg = passthrough[i];
|
||||
if (arg === "-p" || arg.startsWith("-p")) {
|
||||
const port = Number(arg === "-p" ? passthrough[i + 1] : arg.slice(2));
|
||||
if (Number.isInteger(port) && port > 0) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
return process.env.DEVCONTAINER ? 8123 : 8124;
|
||||
@@ -508,62 +582,72 @@ const spawnArgs = (cfg, passthrough) => [
|
||||
];
|
||||
|
||||
const runForegroundProcess = async (suite, cfg, passthrough) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isAlive(existing.pid)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) already running in the background ` +
|
||||
`(pid ${existing.pid}). Stop it with yarn ${cfg.alias} --stop.\n`
|
||||
);
|
||||
const lock = acquireProcessSuite(suite);
|
||||
if (!lock.token) {
|
||||
reportProcessConflict(suite, lock.existing);
|
||||
return 0;
|
||||
}
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
try {
|
||||
return await spawnForeground({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: spawnArgs(cfg, passthrough),
|
||||
cwd: repoRoot,
|
||||
processGroup: true,
|
||||
onSpawn: (child) => updateProcessSuite(suite, lock.token, child),
|
||||
});
|
||||
} finally {
|
||||
releaseProcessSuite(suite, lock.token);
|
||||
}
|
||||
return spawnInherit(cfg.spawn.cmd, spawnArgs(cfg, passthrough));
|
||||
};
|
||||
|
||||
const runBackgroundProcess = async (suite, cfg, passthrough) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isAlive(existing.pid)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) already running${urlSuffix(existing.port)} ` +
|
||||
`(pid ${existing.pid})\n${hints(suite)}`
|
||||
);
|
||||
const lock = acquireProcessSuite(suite);
|
||||
if (!lock.token) {
|
||||
reportProcessConflict(suite, lock.existing);
|
||||
return 0;
|
||||
}
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
|
||||
try {
|
||||
const logFile = logFileFor(suite);
|
||||
const child = await spawnDetachedToLog({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: spawnArgs(cfg, passthrough),
|
||||
cwd: repoRoot,
|
||||
logFile,
|
||||
});
|
||||
|
||||
const port = cfg.acceptsArgs ? resolveServePort(passthrough) : cfg.port;
|
||||
updateProcessSuite(suite, lock.token, child);
|
||||
writePidFile(suite, { ...readPidFile(suite), port });
|
||||
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
logFile,
|
||||
port,
|
||||
isReady: () => logIsReady(logFile, cfg.readyLog),
|
||||
onExit: () => releaseProcessSuite(suite, lock.token),
|
||||
});
|
||||
} catch (err) {
|
||||
releaseProcessSuite(suite, lock.token);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const { child, logFile } = spawnDetachedToLog(
|
||||
suite,
|
||||
cfg.spawn.cmd,
|
||||
spawnArgs(cfg, passthrough)
|
||||
);
|
||||
|
||||
const port = cfg.acceptsArgs ? resolveServePort(passthrough) : cfg.port;
|
||||
writePidFile(suite, { pid: child.pid, port });
|
||||
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
logFile,
|
||||
port,
|
||||
isReady: () => logIsReady(logFile, cfg.readyLog),
|
||||
onExit: () => removePidFile(suite),
|
||||
});
|
||||
};
|
||||
|
||||
const runStatusProcess = async (suite) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isAlive(existing.pid)) {
|
||||
if (existing && isProcessRecordAlive(existing)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) running${urlSuffix(existing.port)} ` +
|
||||
`Dev server (${existing.suite ?? suite}) running${urlSuffix(existing.port)} ` +
|
||||
`(pid ${existing.pid})\n`
|
||||
);
|
||||
} else {
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
removePidFileIf(
|
||||
suite,
|
||||
(current) =>
|
||||
current.token === existing.token && !isProcessRecordAlive(current)
|
||||
);
|
||||
}
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
}
|
||||
@@ -571,56 +655,64 @@ const runStatusProcess = async (suite) => {
|
||||
};
|
||||
|
||||
const runStopProcess = async (suite) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (!existing || !isAlive(existing.pid)) {
|
||||
let existing = readPidFile(suite);
|
||||
if (existing?.starting) {
|
||||
const token = existing.token;
|
||||
await waitFor(
|
||||
() => {
|
||||
const current = readPidFile(suite);
|
||||
return !current?.starting || current.token !== token;
|
||||
},
|
||||
100,
|
||||
5000
|
||||
);
|
||||
existing = readPidFile(suite);
|
||||
}
|
||||
if (!existing || !isProcessRecordAlive(existing)) {
|
||||
// Idempotent: stopping something that is not running is a success.
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
removePidFileIf(
|
||||
suite,
|
||||
(current) =>
|
||||
current.token === existing.token && !isProcessRecordAlive(current)
|
||||
);
|
||||
}
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
return 0;
|
||||
}
|
||||
const { pid } = existing;
|
||||
const activeSuite = existing.suite ?? suite;
|
||||
return terminate(
|
||||
suite,
|
||||
activeSuite,
|
||||
pid,
|
||||
() => !isAlive(pid),
|
||||
() => removePidFile(suite)
|
||||
() => !isProcessRecordAlive(existing),
|
||||
() => releaseProcessSuite(activeSuite, existing.token)
|
||||
);
|
||||
};
|
||||
|
||||
// --- shared -----------------------------------------------------------------
|
||||
|
||||
const runLogs = (suite, follow) => {
|
||||
const logFile = logFileFor(suite);
|
||||
if (!fs.existsSync(logFile)) {
|
||||
process.stdout.write(
|
||||
`No log for the ${suite} dev server yet (${logFile}).\n`
|
||||
);
|
||||
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" });
|
||||
tail.on("error", () => {
|
||||
// No tail available; fall back to a one-shot dump.
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
resolve(0);
|
||||
});
|
||||
tail.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
const activeSuite = readPidFile(suite)?.suite ?? suite;
|
||||
return outputLog(
|
||||
logFileFor(activeSuite),
|
||||
follow,
|
||||
`No log for the ${activeSuite} dev server yet (${logFileFor(activeSuite)}).\n`
|
||||
);
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cfg = SUITES[args.suite];
|
||||
const cfg = SUITES.get(args.suite);
|
||||
if (!cfg) {
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
if (args.modes.length > 1 || (args.follow && args.mode !== "logs")) {
|
||||
process.stderr.write("Invalid combination of lifecycle arguments.\n");
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
if (args.passthrough.length && !cfg.acceptsArgs) {
|
||||
process.stderr.write(
|
||||
`Ignoring unexpected arguments: ${args.passthrough.join(" ")}\n`
|
||||
@@ -644,35 +736,26 @@ const main = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const health = cfg.liveness === "health";
|
||||
switch (mode) {
|
||||
case "background":
|
||||
return health
|
||||
? runBackgroundHealth(args.suite, cfg)
|
||||
: runBackgroundProcess(args.suite, cfg, args.passthrough);
|
||||
case "status":
|
||||
return health
|
||||
? runStatusHealth(args.suite, cfg)
|
||||
: runStatusProcess(args.suite);
|
||||
case "stop":
|
||||
return health
|
||||
? runStopHealth(args.suite, cfg)
|
||||
: runStopProcess(args.suite);
|
||||
case "logs":
|
||||
return runLogs(args.suite, args.follow);
|
||||
default:
|
||||
return health
|
||||
? runForegroundHealth(args.suite, cfg)
|
||||
: runForegroundProcess(args.suite, cfg, args.passthrough);
|
||||
if (mode === "logs") {
|
||||
return runLogs(args.suite, args.follow);
|
||||
}
|
||||
const handlers =
|
||||
cfg.liveness === "health"
|
||||
? {
|
||||
foreground: () => runForegroundHealth(args.suite, cfg),
|
||||
background: () => runBackgroundHealth(args.suite, cfg),
|
||||
status: () => runStatusHealth(args.suite, cfg),
|
||||
stop: () => runStopHealth(args.suite, cfg),
|
||||
}
|
||||
: {
|
||||
foreground: () =>
|
||||
runForegroundProcess(args.suite, cfg, args.passthrough),
|
||||
background: () =>
|
||||
runBackgroundProcess(args.suite, cfg, args.passthrough),
|
||||
status: () => runStatusProcess(args.suite),
|
||||
stop: () => runStopProcess(args.suite),
|
||||
};
|
||||
return handlers[mode]();
|
||||
};
|
||||
|
||||
main().then(
|
||||
(code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
(err) => {
|
||||
process.stderr.write(`${err?.stack || err}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
);
|
||||
runCli(main);
|
||||
|
||||
@@ -51,6 +51,29 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-app-modern",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
"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(
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -160,6 +160,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(
|
||||
|
||||
@@ -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"]])
|
||||
);
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export const LIFECYCLE_MODE_FLAGS = new Map([
|
||||
["--background", "background"],
|
||||
["--status", "status"],
|
||||
["--stop", "stop"],
|
||||
["--logs", "logs"],
|
||||
]);
|
||||
|
||||
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,
|
||||
processGroup = false,
|
||||
onSpawn,
|
||||
}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, {
|
||||
cwd,
|
||||
detached: processGroup,
|
||||
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, 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,
|
||||
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();
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
+3
-3
@@ -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",
|
||||
@@ -193,7 +193,7 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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";
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+27
-5
@@ -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" });
|
||||
|
||||
@@ -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 +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,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { HomeAssistantApi } from "../types";
|
||||
|
||||
export const fetchSlug = (
|
||||
api: HomeAssistantApi,
|
||||
text: string
|
||||
): Promise<{ slug: string }> =>
|
||||
api.callWS<{ slug: string }>({
|
||||
type: "slugify",
|
||||
text,
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
|
||||
@@ -3,10 +3,9 @@ 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";
|
||||
@@ -24,13 +23,11 @@ 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 {
|
||||
@@ -50,10 +47,6 @@ 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 {
|
||||
@@ -64,47 +57,12 @@ export class HaConfigEntityIdFormat extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
private _examples: Record<EntityIdPart, string> = {
|
||||
area: "Living room",
|
||||
device: "Thermostat",
|
||||
entity: "Temperature",
|
||||
floor: "Ground floor",
|
||||
};
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
@@ -173,20 +131,13 @@ 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>
|
||||
${
|
||||
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>`
|
||||
}
|
||||
<code>${EXAMPLE_DOMAIN}.${example}</code>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
+119
-8
@@ -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) =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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": {
|
||||
@@ -3955,6 +3955,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?",
|
||||
@@ -8648,14 +8651,7 @@
|
||||
"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",
|
||||
|
||||
+24
-18
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 */
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user