Add tabbed settings and system shell

This commit is contained in:
2026-07-31 21:30:51 +02:00
parent d663979ebb
commit c36e5e811f
20 changed files with 698 additions and 29 deletions
+16 -4
View File
@@ -18,6 +18,7 @@ The current MVP slice includes:
- atomic, dashboard-triggered Git updates with automatic verification and rollback safety;
- optional dual-radio Wi-Fi failover with an always-recoverable offline car hotspot;
- authenticated live network diagnostics for hotspot clients, DNS query metadata, and active destination flows;
- tabbed Network, System, and restricted Shell settings for on-device administration;
- automated API security tests and reproducible quality commands.
The ADB transport, job UI, artifacts, Android companion, and remaining operations work are tracked in [PLAN.md](./PLAN.md).
@@ -42,7 +43,7 @@ cd pi-car-companion
The script asks for sudo access and then:
- installs Node.js 22, Git, Avahi, SQLite, and native build requirements;
- installs Node.js 22, Git, Avahi, SQLite, the complete hotspot/Realtek networking stack, and native build requirements;
- creates a dedicated `pi-companion` service account;
- builds and verifies the server and dashboard;
- installs an atomic release under `/opt/pi-car-companion`;
@@ -62,7 +63,7 @@ git pull --ff-only
## Dashboard updates
Open **Settings** and select **Update now**. The Pi will:
Open **Settings → System**, select **Check for updates**, then choose **Install update**. The Pi will:
1. fetch the configured branch from `origin`;
2. refuse non-fast-forward history changes;
@@ -178,7 +179,7 @@ Then create the recovery hotspot and enable its controller:
sudo pi-car-companion-network-setup
```
The helper requires `wlan1` to be connected before it enables switching, asks interactively for a unique hotspot password, and never places that password in the application configuration. Alternatively, an authenticated administrator can configure or rotate the hotspot SSID and password directly in **Settings → Car network**. The dashboard uses a narrowly scoped root service; the one-time password request is deleted before processing and NetworkManager stores only the derived WPA key.
The helper requires `wlan1` to be connected before it enables switching, asks interactively for a unique hotspot password, and never places that password in the application configuration. Alternatively, an authenticated administrator can configure or rotate the hotspot SSID and password directly in **Settings → Network**. The dashboard uses a narrowly scoped root service; the one-time password request is deleted before processing and NetworkManager stores only the derived WPA key.
Settings also shows the current mode and offers **Try upstream again** and **Restore hotspot** controls. Applying new hotspot credentials can disconnect the dashboard, after which devices must reconnect using the new values.
@@ -233,6 +234,17 @@ If the device is disconnected, offline, unauthorized, partially readable, or ADB
missing, the dashboard reports that state without substituting generated data. Do not
add shell strings or state-changing commands to this collector.
### System controls and shell
**Settings → System** provides confirmed reboot and power-off actions through a fixed,
sudo-allowlisted helper. It also separates remote update checks from installation.
**Settings → Shell** opens an authenticated WebSocket and a real local pseudo-terminal.
The shell runs as the restricted `pi-companion` service account inside the companion
service sandbox; it is not a root shell and does not request or store SSH credentials.
The WebSocket requires the active session, a trusted browser origin, and the session's
CSRF token. A terminal closes when its tab is left and expires after 30 minutes.
## Safety boundary
This project does not modify the MG4 firmware, launcher, vehicle controls, CAN bus, or safety systems. It does not expose arbitrary shell commands, SSH, ADB, the Docker socket, or unrestricted filesystem access through the dashboard.
This project does not modify the MG4 firmware, launcher, vehicle controls, CAN bus, or safety systems. The authenticated dashboard includes a restricted `pi-companion` shell for local diagnostics, but it does not expose a root shell, SSH credentials, the Docker socket, unrestricted filesystem access, or arbitrary ADB commands.
+4 -1
View File
@@ -52,6 +52,7 @@ apt-get install -y \
iproute2 \
network-manager \
nftables \
python3 \
rsync \
sqlite3 \
sudo \
@@ -137,9 +138,11 @@ ln -sfn "releases/${revision}" "${INSTALL_ROOT}/current.next"
mv -Tf "${INSTALL_ROOT}/current.next" "${CURRENT}"
echo "[5/7] Installing system services"
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-update" /usr/local/sbin/pi-car-companion-update
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-network-setup" /usr/local/sbin/pi-car-companion-network-setup
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-power" /usr/local/sbin/pi-car-companion-power
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-update" /usr/local/sbin/pi-car-companion-update
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion.service" /etc/systemd/system/pi-car-companion.service
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-update-check.service" /etc/systemd/system/pi-car-companion-update-check.service
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-update.service" /etc/systemd/system/pi-car-companion-update.service
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network.service" /etc/systemd/system/pi-car-companion-network.service
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network-configure.service" /etc/systemd/system/pi-car-companion-network-configure.service
+106
View File
@@ -1113,6 +1113,27 @@
],
"license": "MIT"
},
"node_modules/@fastify/websocket": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.0.tgz",
"integrity": "sha512-g89ag4BCcD9YP5wBZXixzoLnuf5j89p/sXFcfpCiv2pdEkYYukBEoK3heVzqsp0EAtszVDc2BBZG0KZqeAShIA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"duplexify": "^4.1.3",
"fastify-plugin": "^6.0.0",
"ws": "^8.16.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1748,6 +1769,16 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
@@ -2166,6 +2197,21 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@@ -2725,6 +2771,18 @@
"node": ">=8"
}
},
"node_modules/duplexify": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.4.1",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1",
"stream-shift": "^1.0.2"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.399",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz",
@@ -3783,6 +3841,22 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/node-pty/node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.51",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
@@ -4448,6 +4522,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/stream-shift": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -4980,6 +5060,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -5015,14 +5116,17 @@
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/rate-limit": "^10.3.0",
"@fastify/websocket": "^11.3.0",
"argon2": "^0.44.0",
"better-sqlite3": "^12.2.0",
"fastify": "^5.4.0",
"node-pty": "^1.1.0",
"zod": "^4.0.14"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.17.0",
"@types/ws": "^8.18.1",
"tsx": "^4.20.3"
}
},
@@ -5031,6 +5135,8 @@
"version": "0.1.0",
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
+1 -1
View File
@@ -16,7 +16,7 @@
"typecheck": "npm run typecheck --workspaces --if-present",
"test": "npm run test --workspaces --if-present",
"build": "npm run build --workspaces --if-present",
"check:scripts": "bash -n install.sh scripts/pi-car-companion-update scripts/pi-car-companion-network-setup",
"check:scripts": "bash -n install.sh scripts/pi-car-companion-update scripts/pi-car-companion-network-setup scripts/pi-car-companion-power",
"check": "npm run check:scripts && npm run lint && npm run typecheck && npm test && npm run build"
},
"devDependencies": {
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -Eeuo pipefail
if [[ ${EUID} -ne 0 ]]; then
echo "The power helper must run as root." >&2
exit 1
fi
case "${1:-}" in
reboot|poweroff)
action="$1"
;;
*)
echo "Usage: pi-car-companion-power reboot|poweroff" >&2
exit 2
;;
esac
/usr/bin/systemd-run \
--quiet \
--collect \
--on-active=3s \
--unit="pi-car-companion-${action}" \
/usr/bin/systemctl "${action}"
+20 -3
View File
@@ -18,6 +18,14 @@ RELEASE_TMP=""
FROM_REVISION=""
TO_REVISION=""
PHASE="checking for updates"
CHECK_ONLY=false
if [[ "${1:-}" == "--check-only" ]]; then
CHECK_ONLY=true
elif [[ $# -gt 0 ]]; then
echo "Usage: pi-car-companion-update [--check-only]" >&2
exit 2
fi
if [[ ${EUID} -ne 0 ]]; then
echo "The update helper must run as root." >&2
@@ -57,8 +65,10 @@ install_integration_files() {
local source_release="$1"
install -m 0755 "${source_release}/scripts/pi-car-companion-update" /usr/local/sbin/pi-car-companion-update
install -m 0755 "${source_release}/scripts/pi-car-companion-network-setup" /usr/local/sbin/pi-car-companion-network-setup
install -m 0755 "${source_release}/scripts/pi-car-companion-power" /usr/local/sbin/pi-car-companion-power
install -m 0644 "${source_release}/systemd/pi-car-companion.service" /etc/systemd/system/pi-car-companion.service
install -m 0644 "${source_release}/systemd/pi-car-companion-update.service" /etc/systemd/system/pi-car-companion-update.service
install -m 0644 "${source_release}/systemd/pi-car-companion-update-check.service" /etc/systemd/system/pi-car-companion-update-check.service
install -m 0644 "${source_release}/systemd/pi-car-companion-network.service" /etc/systemd/system/pi-car-companion-network.service
install -m 0644 "${source_release}/systemd/pi-car-companion-network-configure.service" /etc/systemd/system/pi-car-companion-network-configure.service
install -m 0644 "${source_release}/systemd/pi-car-companion-network-observer.service" /etc/systemd/system/pi-car-companion-network-observer.service
@@ -115,9 +125,11 @@ fi
TO_REVISION="$(git -C "${REPOSITORY}" rev-parse "origin/${branch}")"
if [[ -n "${FROM_REVISION}" && "${FROM_REVISION}" == "${TO_REVISION}" ]]; then
install_integration_files "${CURRENT}"
visudo -cf /etc/sudoers.d/pi-car-companion >/dev/null
systemctl daemon-reload
if [[ "${CHECK_ONLY}" != "true" ]]; then
install_integration_files "${CURRENT}"
visudo -cf /etc/sudoers.d/pi-car-companion >/dev/null
systemctl daemon-reload
fi
write_status "current" "Pi Car Companion is already up to date."
exit 0
fi
@@ -127,6 +139,11 @@ if [[ -n "${FROM_REVISION}" ]] && ! git -C "${REPOSITORY}" merge-base --is-ances
exit 1
fi
if [[ "${CHECK_ONLY}" == "true" ]]; then
write_status "available" "A newer Pi Car Companion release is available to install."
exit 0
fi
PHASE="building the new release"
write_status "building" "Downloading dependencies and verifying the new release."
install -d -m 0755 "${STAGING_ROOT}" "${RELEASES}"
+3
View File
@@ -15,14 +15,17 @@
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/rate-limit": "^10.3.0",
"@fastify/websocket": "^11.3.0",
"argon2": "^0.44.0",
"better-sqlite3": "^12.2.0",
"fastify": "^5.4.0",
"node-pty": "^1.1.0",
"zod": "^4.0.14"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.17.0",
"@types/ws": "^8.18.1",
"tsx": "^4.20.3"
}
}
+72 -1
View File
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import argon2 from "argon2";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import websocket from "@fastify/websocket";
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
import { z } from "zod";
import type { AppConfig } from "./config.js";
@@ -28,6 +29,8 @@ import {
requestNetworkAction
} from "./network.js";
import { collectAdbStatus } from "./adb.js";
import { openShell } from "./shell.js";
import { systemPowerActionSchema, triggerSystemPower } from "./system.js";
import "./types.js";
const usernameSchema = z
@@ -114,6 +117,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
app.decorateRequest("authUser", null);
await app.register(cookie);
await app.register(rateLimit, { global: false });
await app.register(websocket);
app.addHook("onClose", async () => {
jobRunner.close();
@@ -405,6 +409,37 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
return readUpdateStatus(config);
});
app.post(
"/api/system/update/check",
{ config: { rateLimit: { max: 6, timeWindow: "10 minutes" } } },
async (request, reply) => {
if (!requireUser(request, reply)) return;
const current = await readUpdateStatus(config);
if (!current.supported) {
return reply.code(503).send({
error: { code: "UPDATES_UNAVAILABLE", message: "Install the system service before checking for updates" }
});
}
if (["checking", "building"].includes(current.state)) {
return reply.code(409).send({ error: { code: "UPDATE_IN_PROGRESS", message: "An update task is already running" } });
}
try {
await triggerSystemUpdate("check");
audit(database, {
actorUserId: request.authUser!.id,
action: "system.update-check",
result: "success",
ipAddress: clientAddress(request)
});
return reply.code(202).send({ queued: true });
} catch {
return reply.code(503).send({
error: { code: "UPDATE_CHECK_FAILED", message: "The update check could not be started" }
});
}
}
);
app.post(
"/api/system/update",
{ config: { rateLimit: { max: 2, timeWindow: "10 minutes" } } },
@@ -421,7 +456,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
}
try {
await triggerSystemUpdate();
await triggerSystemUpdate("install");
audit(database, {
actorUserId: request.authUser!.id,
action: "system.update",
@@ -445,6 +480,42 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
}
);
app.post(
"/api/system/power",
{ config: { rateLimit: { max: 3, timeWindow: "10 minutes" } } },
async (request, reply) => {
if (!requireUser(request, reply)) return;
const parsed = z.object({ action: systemPowerActionSchema }).safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: { code: "INVALID_POWER_ACTION", message: "That power action is not available" } });
}
try {
await triggerSystemPower(parsed.data.action);
audit(database, {
actorUserId: request.authUser!.id,
action: `system.${parsed.data.action}`,
result: "success",
ipAddress: clientAddress(request)
});
return reply.code(202).send({ queued: true, action: parsed.data.action });
} catch {
audit(database, {
actorUserId: request.authUser!.id,
action: `system.${parsed.data.action}`,
result: "failure",
ipAddress: clientAddress(request)
});
return reply.code(503).send({
error: { code: "POWER_ACTION_FAILED", message: "The Pi could not schedule that power action" }
});
}
}
);
app.get("/api/system/shell", { websocket: true }, (socket, request) => {
openShell(socket, request, config);
});
app.get("/api/events", async (request, reply) => {
if (!requireUser(request, reply)) return;
reply.hijack();
+124
View File
@@ -0,0 +1,124 @@
import type { FastifyRequest } from "fastify";
import { dirname } from "node:path";
import * as pty from "node-pty";
import type { WebSocket } from "ws";
import type { AppConfig } from "./config.js";
import { CSRF_COOKIE, safeEqual } from "./security.js";
type ClientMessage =
| { type: "authenticate"; csrfToken: string }
| { type: "input"; data: string }
| { type: "resize"; cols: number; rows: number };
function send(socket: WebSocket, value: Record<string, unknown>): void {
if (socket.readyState === 1) socket.send(JSON.stringify(value));
}
function trustedOrigin(request: FastifyRequest, config: AppConfig): boolean {
const origin = request.headers.origin;
if (!origin) return config.nodeEnv === "test";
try {
const parsed = new URL(origin);
return (
(["http:", "https:"].includes(parsed.protocol) && Boolean(request.headers.host) && parsed.host === request.headers.host) ||
config.allowedOrigins.has(origin)
);
} catch {
return false;
}
}
export function openShell(socket: WebSocket, request: FastifyRequest, config: AppConfig): void {
if (!request.authUser || !trustedOrigin(request, config)) {
socket.close(1008, "Authentication required");
return;
}
let terminal: pty.IPty | null = null;
const authenticationTimer = setTimeout(() => socket.close(1008, "Authentication timed out"), 5_000);
const lifetimeTimer = setTimeout(() => socket.close(1000, "Terminal session expired"), 30 * 60 * 1_000);
const closeTerminal = () => {
clearTimeout(authenticationTimer);
clearTimeout(lifetimeTimer);
if (terminal) {
try {
terminal.kill();
} catch {
// The shell may already have exited.
}
terminal = null;
}
};
socket.on("close", closeTerminal);
socket.on("error", closeTerminal);
socket.on("message", (raw) => {
const serialized = Array.isArray(raw)
? Buffer.concat(raw).toString("utf8")
: raw instanceof ArrayBuffer
? Buffer.from(new Uint8Array(raw)).toString("utf8")
: raw.toString("utf8");
if (Buffer.byteLength(serialized) > 16_384) {
socket.close(1009, "Message too large");
return;
}
let message: ClientMessage;
try {
message = JSON.parse(serialized) as ClientMessage;
} catch {
socket.close(1003, "Invalid terminal message");
return;
}
if (!terminal) {
const cookieToken = request.cookies[CSRF_COOKIE];
if (
message.type !== "authenticate" ||
typeof message.csrfToken !== "string" ||
!cookieToken ||
!safeEqual(cookieToken, message.csrfToken)
) {
socket.close(1008, "Authentication failed");
return;
}
clearTimeout(authenticationTimer);
const shellHome = dirname(config.databasePath);
terminal = pty.spawn("/bin/bash", ["--noprofile", "--norc"], {
name: "xterm-256color",
cols: 100,
rows: 28,
cwd: shellHome,
env: {
HOME: shellHome,
LANG: process.env.LANG ?? "C.UTF-8",
PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
SHELL: "/bin/bash",
TERM: "xterm-256color",
USER: "pi-companion"
}
});
terminal.onData((data) => send(socket, { type: "output", data }));
terminal.onExit(({ exitCode }) => {
terminal = null;
send(socket, { type: "exit", exitCode });
socket.close(1000, "Shell exited");
});
send(socket, { type: "ready" });
return;
}
if (message.type === "input" && typeof message.data === "string" && message.data.length <= 8_192) {
terminal.write(message.data);
} else if (
message.type === "resize" &&
Number.isInteger(message.cols) &&
Number.isInteger(message.rows) &&
message.cols >= 20 && message.cols <= 240 &&
message.rows >= 5 && message.rows <= 100
) {
terminal.resize(message.cols, message.rows);
}
});
}
+15
View File
@@ -0,0 +1,15 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { z } from "zod";
const execFileAsync = promisify(execFile);
export const systemPowerActionSchema = z.enum(["reboot", "poweroff"]);
export type SystemPowerAction = z.infer<typeof systemPowerActionSchema>;
export async function triggerSystemPower(action: SystemPowerAction): Promise<void> {
await execFileAsync("/usr/bin/sudo", ["-n", "/usr/local/sbin/pi-car-companion-power", action], {
timeout: 10_000,
maxBuffer: 16 * 1024
});
}
+9 -3
View File
@@ -7,7 +7,7 @@ import type { AppConfig } from "./config.js";
const execFileAsync = promisify(execFile);
const updateStatusSchema = z.object({
state: z.enum(["idle", "checking", "building", "current", "success", "failed"]),
state: z.enum(["idle", "checking", "available", "building", "current", "success", "failed"]),
message: z.string().max(500),
fromRevision: z.string().max(64).nullable().default(null),
toRevision: z.string().max(64).nullable().default(null),
@@ -56,10 +56,16 @@ export async function readUpdateStatus(config: AppConfig): Promise<UpdateStatus>
}
}
export async function triggerSystemUpdate(): Promise<void> {
export async function triggerSystemUpdate(mode: "check" | "install" = "install"): Promise<void> {
await execFileAsync(
"/usr/bin/sudo",
["-n", "/usr/bin/systemctl", "start", "--no-block", "pi-car-companion-update.service"],
[
"-n",
"/usr/bin/systemctl",
"start",
"--no-block",
mode === "check" ? "pi-car-companion-update-check.service" : "pi-car-companion-update.service"
],
{ timeout: 10_000, maxBuffer: 16 * 1024 }
);
}
+23
View File
@@ -179,6 +179,29 @@ describe("authentication boundary", () => {
});
expect(update.statusCode).toBe(503);
expect(update.json()).toMatchObject({ error: { code: "UPDATES_UNAVAILABLE" } });
const check = await app.inject({
method: "POST",
url: "/api/system/update/check",
headers: {
...mutationHeaders(token),
cookie: `${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=${sessionCookie?.value ?? ""}`
}
});
expect(check.statusCode).toBe(503);
expect(check.json()).toMatchObject({ error: { code: "UPDATES_UNAVAILABLE" } });
const invalidPower = await app.inject({
method: "POST",
url: "/api/system/power",
headers: {
...mutationHeaders(token),
cookie: `${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=${sessionCookie?.value ?? ""}`
},
payload: { action: "factory-reset" }
});
expect(invalidPower.statusCode).toBe(400);
expect(invalidPower.json()).toMatchObject({ error: { code: "INVALID_POWER_ACTION" } });
});
it("does not expose update state without authentication", async () => {
+26
View File
@@ -58,6 +58,32 @@ describe("system update status", () => {
});
});
it("reports a check-only result when a newer revision is available", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-car-update-test-"));
temporaryDirectories.push(directory);
const statusPath = join(directory, "status.json");
const versionPath = join(directory, "REVISION");
await writeFile(
statusPath,
JSON.stringify({
state: "available",
message: "A newer release is available.",
fromRevision: "a".repeat(40),
toRevision: "b".repeat(40),
updatedAt: "2026-07-31T18:00:00.000Z"
})
);
await writeFile(versionPath, `${"a".repeat(40)}\n`);
await expect(readUpdateStatus(config(statusPath, versionPath))).resolves.toMatchObject({
state: "available",
fromRevision: "a".repeat(40),
toRevision: "b".repeat(40),
installedRevision: "a".repeat(40),
supported: true
});
});
it("treats missing or malformed status files as unsupported", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-car-update-test-"));
temporaryDirectories.push(directory);
@@ -0,0 +1,13 @@
[Unit]
Description=Check for Pi Car Companion updates
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
Group=root
ExecStart=/usr/local/sbin/pi-car-companion-update --check-only
TimeoutStartSec=5min
Nice=10
IOSchedulingClass=idle
+3
View File
@@ -1,2 +1,5 @@
pi-companion ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pi-car-companion-update.service
pi-companion ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pi-car-companion-update-check.service
pi-companion ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pi-car-companion-network-configure.service
pi-companion ALL=(root) NOPASSWD: /usr/local/sbin/pi-car-companion-power reboot
pi-companion ALL=(root) NOPASSWD: /usr/local/sbin/pi-car-companion-power poweroff
+2
View File
@@ -12,6 +12,8 @@
},
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
+92 -11
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { lazy, Suspense, useCallback, useEffect, useState, type FormEvent } from "react";
import {
ArrowClockwise,
CheckCircle,
@@ -9,6 +9,7 @@ import {
Gear,
GitBranch,
DownloadSimple,
Power,
SignOut,
TerminalWindow,
WarningCircle,
@@ -29,6 +30,11 @@ import {
type UpdateStatus
} from "./api";
const ShellTerminal = lazy(async () => {
const module = await import("./ShellTerminal");
return { default: module.ShellTerminal };
});
type Screen = "loading" | "setup" | "login" | "dashboard" | "fatal";
export function App() {
@@ -311,12 +317,15 @@ function portLabel(port: number): string {
}
function SettingsPage({ csrfToken }: { csrfToken: string }) {
const [tab, setTab] = useState<"network" | "system" | "shell">("network");
const [update, setUpdate] = useState<UpdateStatus | null>(null);
const [network, setNetwork] = useState<NetworkStatus | null>(null);
const [updateError, setUpdateError] = useState("");
const [networkError, setNetworkError] = useState("");
const [systemNotice, setSystemNotice] = useState("");
const [requesting, setRequesting] = useState(false);
const [confirming, setConfirming] = useState(false);
const [powerAction, setPowerAction] = useState<"reboot" | "poweroff" | null>(null);
const [networkAction, setNetworkAction] = useState<"retry-upstream" | "restore-hotspot" | null>(null);
const [hotspotSsid, setHotspotSsid] = useState("MG4-Companion");
const [hotspotPassword, setHotspotPassword] = useState("");
@@ -372,6 +381,35 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
}
}
async function checkForUpdates() {
setRequesting(true);
setUpdateError("");
try {
await post("/api/system/update/check", {}, csrfToken);
window.setTimeout(() => void refreshUpdate(), 700);
} catch (caught) {
setUpdateError(caught instanceof ApiError ? caught.message : "The update check could not be started");
} finally {
setRequesting(false);
}
}
async function requestPowerAction() {
if (!powerAction) return;
const action = powerAction;
setPowerAction(null);
setRequesting(true);
setUpdateError("");
try {
await post("/api/system/power", { action }, csrfToken);
setSystemNotice(action === "reboot" ? "Reboot scheduled. The dashboard will disconnect briefly." : "Power off scheduled. The Pi will shut down safely.");
} catch (caught) {
setUpdateError(caught instanceof ApiError ? caught.message : "The power action could not be scheduled");
} finally {
setRequesting(false);
}
}
async function requestNetworkAction() {
if (!networkAction) return;
const action = networkAction;
@@ -426,7 +464,7 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
}
const revision = update?.installedRevision?.slice(0, 12) ?? "Unavailable";
const statusLabel = !update ? "Loading" : update.state === "current" ? "Up to date" : update.state === "success" ? "Updated" : update.state === "failed" ? "Needs attention" : running ? "Updating" : "Ready";
const statusLabel = !update ? "Loading" : update.state === "current" ? "Up to date" : update.state === "available" ? "Update available" : update.state === "success" ? "Updated" : update.state === "failed" ? "Needs attention" : running ? (update.state === "checking" ? "Checking" : "Updating") : "Ready";
const modeLabels: Record<NonNullable<NetworkStatus>["mode"], string> = {
disabled: "Not configured",
starting: "Starting",
@@ -442,11 +480,18 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
: null;
return (
<div className="settings-layout">
<div className="settings-page">
<nav className="settings-tabs" aria-label="Settings categories" role="tablist">
<button role="tab" aria-selected={tab === "network"} className={tab === "network" ? "active" : ""} onClick={() => setTab("network")}><WifiHigh size={20} />Network</button>
<button role="tab" aria-selected={tab === "system"} className={tab === "system" ? "active" : ""} onClick={() => setTab("system")}><Gear size={20} />System</button>
<button role="tab" aria-selected={tab === "shell"} className={tab === "shell" ? "active" : ""} onClick={() => setTab("shell")}><TerminalWindow size={20} />Shell</button>
</nav>
{tab === "network" && <div className="settings-layout" role="tabpanel">
<section className="settings-panel network-panel">
<div className="settings-title">
<div className="settings-icon"><WifiHigh size={30} weight="duotone" /></div>
<div><h2>Car network</h2><p>Keep the dashboard reachable through the built-in hotspot while preferring the AWUS1900 for upstream internet.</p></div>
<div><h2>Network</h2><p>The built-in radio hosts the car network while the AWUS1900 provides upstream internet whenever it is available.</p></div>
</div>
<div className="network-overview">
@@ -456,7 +501,11 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
<p>{network?.message ?? "Reading network controller status..."}</p>
{probeSeconds !== null && <small>Offline hotspot recovery in at most {probeSeconds} seconds</small>}
</div>
<NetworkAdapter label="Built-in / car hotspot" adapter={network?.hotspot ?? null} />
<div className="network-adapter">
<span>Hotspot</span>
<strong>{network?.hotspotSsid ?? (network ? "Not active" : "Loading")}</strong>
<small>{network?.hotspot.address ?? network?.hotspot.interface ?? "Waiting for status"}</small>
</div>
<NetworkAdapter label="AWUS1900 / upstream" adapter={network?.upstream ?? null} />
<div className="network-adapter">
<span>Internet check</span>
@@ -500,17 +549,33 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
<p className="settings-note">The password is sent only to the local Pi configuration service and is stored by NetworkManager as a derived WPA key.</p>
</form>
</section>
</div>}
<section className="settings-panel">
{tab === "system" && <div className="system-settings-layout" role="tabpanel">
<section className="settings-panel power-panel">
<div className="settings-title">
<div className="settings-icon"><Power size={30} weight="duotone" /></div>
<div><h2>General system</h2><p>Restart or safely shut down the Raspberry Pi.</p></div>
</div>
{systemNotice && <div className="network-notice" role="status"><CheckCircle size={20} />{systemNotice}</div>}
<div className="power-actions">
<button className="secondary-button text-button" onClick={() => setPowerAction("reboot")} disabled={requesting}><ArrowClockwise size={22} />Reboot Pi</button>
<button className="danger-button" onClick={() => setPowerAction("poweroff")} disabled={requesting}><Power size={22} />Power off</button>
</div>
<p className="settings-note">Both actions use systemd for a clean shutdown. Power can be removed only after the Pi has stopped.</p>
</section>
<section className="settings-panel update-panel">
<div className="settings-title">
<div className="settings-icon"><DownloadSimple size={30} weight="duotone" /></div>
<div><h2>Software update</h2><p>Fetch, verify, build, and activate the newest release from the configured Git branch.</p></div>
<div><h2>Software update</h2><p>Check the configured Git branch, then verify and activate a newer release when you choose.</p></div>
</div>
<dl className="update-facts">
<div><dt>Status</dt><dd className={`update-state ${update?.state ?? "idle"}`}>{statusLabel}</dd></div>
<div><dt>Installed revision</dt><dd><GitBranch size={18} /> <code>{revision}</code></dd></div>
<div><dt>Last activity</dt><dd>{update && update.updatedAt !== new Date(0).toISOString() ? new Date(update.updatedAt).toLocaleString() : "Never"}</dd></div>
<div><dt>Available revision</dt><dd><GitBranch size={18} /> <code>{update?.toRevision?.slice(0, 12) ?? "Not checked"}</code></dd></div>
<div><dt>Last check</dt><dd>{update && update.updatedAt !== new Date(0).toISOString() ? new Date(update.updatedAt).toLocaleString() : "Never"}</dd></div>
</dl>
<div className="update-message" aria-live="polite">
@@ -522,10 +587,12 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
{updateError && <div className="form-error" role="alert"><WarningCircle size={20} />{updateError}</div>}
<div className="settings-actions">
<button className="primary-button" onClick={() => setConfirming(true)} disabled={!update?.supported || running || requesting}>
<DownloadSimple size={22} /> {running ? "Updating..." : "Update now"}
<button className="secondary-button text-button" onClick={() => void checkForUpdates()} disabled={!update?.supported || running || requesting}>
<ArrowClockwise size={22} /> {update?.state === "checking" ? "Checking..." : "Check for updates"}
</button>
<button className="primary-button" onClick={() => setConfirming(true)} disabled={!update?.supported || running || requesting}>
<DownloadSimple size={22} /> {update?.state === "building" ? "Updating..." : "Install update"}
</button>
<button className="secondary-button" onClick={() => void refreshUpdate()} disabled={requesting}>Refresh status</button>
</div>
<p className="settings-note">The current release stays active unless the new version passes installation, lint, type checks, tests, and production builds.</p>
</section>
@@ -538,6 +605,9 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
<div><dt>Update channel</dt><dd>{update?.supported ? "Configured Git branch" : "Not configured"}</dd></div>
</dl>
</section>
</div>}
{tab === "shell" && <div role="tabpanel"><Suspense fallback={<div className="shell-loading">Loading terminal</div>}><ShellTerminal csrfToken={csrfToken} /></Suspense></div>}
{confirming && <div className="modal-backdrop" role="presentation" onMouseDown={() => setConfirming(false)}>
<div className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="update-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
@@ -573,6 +643,17 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
</div>
</div>
</div>}
{powerAction && <div className="modal-backdrop" role="presentation" onMouseDown={() => setPowerAction(null)}>
<div className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="power-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
<h2 id="power-dialog-title">{powerAction === "reboot" ? "Reboot the Pi?" : "Power off the Pi?"}</h2>
<p>{powerAction === "reboot" ? "The dashboard and hotspot will disconnect while the Pi restarts, then return automatically." : "The dashboard and hotspot will disconnect. Physical access may be required to turn the Pi back on."}</p>
<div className="dialog-actions">
<button className="secondary-button text-button" onClick={() => setPowerAction(null)}>Cancel</button>
<button className={powerAction === "poweroff" ? "danger-button" : "primary-button"} onClick={() => void requestPowerAction()}>{powerAction === "reboot" ? "Reboot Pi" : "Power off"}</button>
</div>
</div>
</div>}
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
type ServerMessage =
| { type: "ready" }
| { type: "output"; data: string }
| { type: "exit"; exitCode: number }
| { type: "error"; message: string };
export function ShellTerminal({ csrfToken }: { csrfToken: string }) {
const host = useRef<HTMLDivElement>(null);
const [generation, setGeneration] = useState(0);
const [state, setState] = useState<"connecting" | "ready" | "closed" | "error">("connecting");
useEffect(() => {
if (!host.current) return;
const terminal = new Terminal({
cursorBlink: true,
cursorStyle: "bar",
fontFamily: 'ui-monospace, "Cascadia Code", "SFMono-Regular", monospace',
fontSize: 14,
lineHeight: 1.25,
scrollback: 3_000,
theme: {
background: "#0b0e0a",
foreground: "#dce4d6",
cursor: "#b9e769",
selectionBackground: "#43542c",
black: "#10130f",
green: "#b9e769",
brightGreen: "#d0f397",
red: "#ff9b8d"
}
});
const fit = new FitAddon();
terminal.loadAddon(fit);
terminal.open(host.current);
fit.fit();
terminal.writeln("\x1b[38;2;185;231;105mPi Car Companion shell\x1b[0m");
terminal.writeln("Restricted local session running as pi-companion.\r\n");
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${protocol}//${window.location.host}/api/system/shell`);
const sendSize = () => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "resize", cols: terminal.cols, rows: terminal.rows }));
}
};
socket.addEventListener("open", () => {
socket.send(JSON.stringify({ type: "authenticate", csrfToken }));
});
socket.addEventListener("message", (event) => {
try {
const message = JSON.parse(String(event.data)) as ServerMessage;
if (message.type === "ready") {
setState("ready");
sendSize();
terminal.focus();
} else if (message.type === "output") terminal.write(message.data);
else if (message.type === "exit") {
terminal.writeln(`\r\n\x1b[90mShell exited with status ${message.exitCode}.\x1b[0m`);
setState("closed");
} else if (message.type === "error") {
terminal.writeln(`\r\n\x1b[31m${message.message}\x1b[0m`);
setState("error");
}
} catch {
terminal.writeln("\r\n\x1b[31mInvalid response from the shell service.\x1b[0m");
}
});
socket.addEventListener("close", (event) => {
if (event.code !== 1000) {
terminal.writeln(`\r\n\x1b[31mTerminal disconnected${event.reason ? `: ${event.reason}` : "."}\x1b[0m`);
setState("error");
} else setState((current) => current === "error" ? "error" : "closed");
});
socket.addEventListener("error", () => setState("error"));
const input = terminal.onData((data) => {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "input", data }));
});
const resize = new ResizeObserver(() => {
fit.fit();
sendSize();
});
resize.observe(host.current);
return () => {
resize.disconnect();
input.dispose();
socket.close(1000, "Terminal tab closed");
terminal.dispose();
};
}, [csrfToken, generation]);
return (
<section className="shell-panel" aria-label="Interactive Pi shell">
<div className="shell-toolbar">
<div><span className={`shell-state ${state}`} aria-hidden="true" /><strong>{state === "ready" ? "Connected" : state === "connecting" ? "Connecting" : "Disconnected"}</strong><small>pi-companion@localhost</small></div>
{state !== "ready" && <button className="secondary-button text-button" onClick={() => { setState("connecting"); setGeneration((value) => value + 1); }}>Reconnect</button>}
</div>
<div className="terminal-host" ref={host} />
<p className="settings-note">This authenticated terminal runs locally as the restricted <code>pi-companion</code> service account. Sessions close when you leave this tab and expire after 30 minutes.</p>
</section>
);
}
+1 -1
View File
@@ -57,7 +57,7 @@ export type SystemStatus = {
};
export type UpdateStatus = {
state: "idle" | "checking" | "building" | "current" | "success" | "failed";
state: "idle" | "checking" | "available" | "building" | "current" | "success" | "failed";
message: string;
fromRevision: string | null;
toRevision: string | null;
+37 -4
View File
@@ -39,12 +39,14 @@ h2 { letter-spacing: -0.025em; }
.error-screen svg { color: var(--danger); margin-bottom: 22px; }
.screen-copy { max-width: 520px; color: var(--muted); line-height: 1.6; }
.primary-button, .secondary-button { min-height: 56px; display: inline-flex; align-items: center; justify-content: center; gap: 10px; border-radius: 10px; padding: 0 24px; border: 0; font-weight: 750; cursor: pointer; transition: transform 120ms ease, background 120ms ease; white-space: nowrap; }
.primary-button, .secondary-button, .danger-button { min-height: 56px; display: inline-flex; align-items: center; justify-content: center; gap: 10px; border-radius: 10px; padding: 0 24px; border: 0; font-weight: 750; cursor: pointer; transition: transform 120ms ease, background 120ms ease; white-space: nowrap; }
.primary-button { color: var(--accent-ink); background: var(--accent); }
.secondary-button { color: var(--text); background: var(--surface-raised); border: 1px solid var(--line); }
.danger-button { color: #ffe3df; background: #552820; border: 1px solid #804035; }
.primary-button:hover { background: #c8f17f; }
.secondary-button:hover { background: #262c24; }
.primary-button:active, .secondary-button:active, .nav-item:active { transform: scale(0.98); }
.danger-button:hover { background: #683127; }
.primary-button:active, .secondary-button:active, .danger-button:active, .nav-item:active { transform: scale(0.98); }
button:disabled { opacity: 0.55; cursor: not-allowed; }
.account-layout { min-height: 100dvh; display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(420px, 0.95fr); }
@@ -135,7 +137,15 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.activity-empty { min-height: 74px; display: grid; place-items: center; padding: 18px; color: var(--muted); font-size: 0.8rem; text-align: center; }
.activity-privacy { margin: 0; color: var(--muted); font-size: 0.72rem; text-align: right; }
.settings-page { display: grid; gap: 18px; }
.settings-tabs { width: fit-content; display: flex; gap: 5px; padding: 5px; background: #121610; border: 1px solid var(--line); border-radius: 12px; }
.settings-tabs button { min-height: 48px; display: inline-flex; align-items: center; gap: 9px; padding: 0 19px; color: var(--muted); background: transparent; border: 0; border-radius: 8px; font-weight: 700; cursor: pointer; transition: color 160ms ease, background 160ms ease, transform 120ms ease; }
.settings-tabs button:hover { color: var(--text); background: var(--surface-raised); }
.settings-tabs button:active { transform: scale(0.98); }
.settings-tabs button.active { color: var(--accent-ink); background: var(--accent); }
.settings-layout { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.7fr); gap: 18px; align-items: start; }
.system-settings-layout { display: grid; grid-template-columns: minmax(280px, 0.65fr) minmax(0, 1.35fr); gap: 18px; align-items: start; }
.update-panel { grid-row: span 2; }
.settings-panel { padding: 28px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.network-panel { grid-column: 1 / -1; }
.network-overview { display: grid; grid-template-columns: 1.3fr repeat(3, minmax(0, 1fr)); gap: 1px; margin: 28px 0 20px; overflow: hidden; border: 1px solid var(--line); border-radius: 11px; background: var(--line); }
@@ -162,21 +172,36 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.settings-title h2, .compact-panel h2 { margin-bottom: 8px; font-size: 1.45rem; }
.settings-title p { max-width: 620px; margin: 0; color: var(--muted); line-height: 1.5; }
.settings-icon { flex: none; width: 56px; height: 56px; display: grid; place-items: center; color: var(--accent); background: rgba(185, 231, 105, 0.08); border-radius: 12px; }
.update-facts { display: grid; grid-template-columns: 0.8fr 1fr 1.2fr; gap: 20px; margin: 30px 0 20px; padding: 22px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.update-facts { display: grid; grid-template-columns: 0.7fr 1fr 1fr 1.2fr; gap: 20px; margin: 30px 0 20px; padding: 22px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.update-facts div { display: grid; gap: 7px; }
.update-facts dt, .behavior-list dt { color: var(--muted); font-size: 0.8rem; }
.update-facts dd, .behavior-list dd { display: flex; align-items: center; gap: 7px; margin: 0; font-weight: 700; }
.update-facts code { font-family: ui-monospace, "Cascadia Code", monospace; color: #dce4d6; }
.update-state.failed { color: var(--danger); }
.update-state.success, .update-state.current, .update-state.building, .update-state.checking { color: var(--accent); }
.update-state.success, .update-state.current, .update-state.available, .update-state.building, .update-state.checking { color: var(--accent); }
.update-message { min-height: 56px; display: flex; align-items: center; gap: 12px; margin-bottom: 18px; padding: 14px 16px; color: #dce4d6; background: #121610; border-radius: 10px; }
.update-message svg { flex: none; color: var(--accent); }
.update-message .ph-warning-circle { color: var(--danger); }
.settings-actions { display: flex; gap: 12px; margin-top: 22px; }
.settings-note { max-width: 680px; margin: 20px 0 0; color: var(--muted); font-size: 0.83rem; line-height: 1.5; }
.power-panel .network-notice { margin-top: 24px; }
.power-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 30px; }
.behavior-list { display: grid; gap: 24px; margin: 26px 0 0; }
.behavior-list div { display: grid; gap: 7px; }
.behavior-list dd { color: #dce4d6; }
.shell-panel { overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.shell-loading { min-height: 420px; display: grid; place-items: center; color: var(--muted); background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.shell-toolbar { min-height: 68px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 10px 14px 10px 20px; border-bottom: 1px solid var(--line); }
.shell-toolbar > div { display: grid; grid-template-columns: auto auto; align-items: center; gap: 3px 9px; }
.shell-toolbar strong { font-size: 0.87rem; }
.shell-toolbar small { grid-column: 2; color: var(--muted); font: 0.7rem/1.2 ui-monospace, "Cascadia Code", monospace; }
.shell-state { grid-row: 1 / 3; width: 9px; height: 9px; border-radius: 50%; background: #7c8477; }
.shell-state.ready { background: var(--accent); box-shadow: 0 0 0 5px rgba(185, 231, 105, 0.08); }
.shell-state.error { background: var(--danger); }
.terminal-host { height: min(590px, calc(100dvh - 290px)); min-height: 360px; padding: 16px 12px; background: #0b0e0a; }
.terminal-host .xterm { height: 100%; }
.shell-panel > .settings-note { margin: 0; padding: 14px 20px 16px; border-top: 1px solid var(--line); }
.shell-panel code { color: #dce4d6; }
.modal-backdrop { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 24px; background: rgba(8, 10, 8, 0.78); }
.confirm-dialog { width: min(520px, 100%); padding: 28px; background: var(--surface-raised); border: 1px solid #465043; border-radius: var(--radius); box-shadow: 0 24px 80px rgba(3, 5, 3, 0.45); }
.confirm-dialog h2 { margin-bottom: 12px; font-size: 1.6rem; }
@@ -197,6 +222,8 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.health-summary { grid-template-columns: 1fr; gap: 24px; }
.metrics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.settings-layout { grid-template-columns: 1fr; }
.system-settings-layout { grid-template-columns: 1fr; }
.update-panel { grid-row: auto; }
.network-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.hotspot-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.activity-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -231,6 +258,12 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.activity-summary, .client-grid { grid-template-columns: 1fr; }
.settings-actions, .dialog-actions { flex-direction: column; }
.settings-actions button, .dialog-actions button { width: 100%; }
.settings-actions .text-button, .dialog-actions .text-button { font-size: inherit; }
.settings-tabs { width: 100%; }
.settings-tabs button { flex: 1; justify-content: center; padding: 0 10px; }
.power-actions { grid-template-columns: 1fr; }
.power-actions .secondary-button { width: 100%; padding: 0 18px; font-size: inherit; }
.terminal-host { height: 58dvh; min-height: 320px; }
.skeleton-grid { grid-template-columns: 1fr; }
}