diff --git a/android/.gitignore b/android/.gitignore index 3fd896b..3e29ff7 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -4,3 +4,4 @@ app/build/ local.properties *.jks *.keystore +release-keystore-password.txt diff --git a/android/README.md b/android/README.md index 91718f9..3a018b6 100644 --- a/android/README.md +++ b/android/README.md @@ -12,8 +12,9 @@ The Android app is a focused native companion rather than a copy of the web dash 1. Deploy the latest Pi release with `./install.sh` so BlueZ and the Bluetooth bridge are installed. 2. Open **Settings → Mobile** in the web dashboard and select **Pair a phone**. -3. Install and open the Android application and grant Bluetooth, notification, and precise-location permissions. -4. Enter the six-digit code shown by the dashboard. +3. If needed, turn Bluetooth on and select **Make discoverable**. The Pi remains discoverable for three minutes. +4. Install and open the Android application and grant Bluetooth, notification, and precise-location permissions. +5. Enter the six-digit code shown by the dashboard. The one-use code expires after ten minutes. The resulting credential is encrypted with Android Keystore and can be revoked from **Settings → Mobile**. A phone can only launch existing allowlisted ADB shortcuts; the Bluetooth protocol has no shell, arbitrary ADB, APK upload, update, or power operation. diff --git a/install.sh b/install.sh index 5213b8d..01ea1d5 100755 --- a/install.sh +++ b/install.sh @@ -56,6 +56,7 @@ apt-get install -y \ python3 \ python3-dbus \ python3-gi \ + rfkill \ rsync \ sqlite3 \ sudo \ @@ -64,7 +65,7 @@ apt-get install -y \ wireless-regdb \ wpasupplicant -for required_command in conntrack dnsmasq ip journalctl nmcli nft uuidgen wpa_passphrase; do +for required_command in conntrack dnsmasq ip journalctl nmcli nft rfkill uuidgen wpa_passphrase; do if ! command -v "${required_command}" >/dev/null 2>&1; then echo "Required network command was not installed: ${required_command}" >&2 exit 1 @@ -143,6 +144,7 @@ mv -Tf "${INSTALL_ROOT}/current.next" "${CURRENT}" echo "[5/7] Installing system services" 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-bluetooth-control" /usr/local/sbin/pi-car-companion-bluetooth-control install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-update" /usr/local/sbin/pi-car-companion-update install -d -m 0755 /usr/local/libexec install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-bluetooth" /usr/local/libexec/pi-car-companion-bluetooth diff --git a/package.json b/package.json index 17e16cb..7599381 100644 --- a/package.json +++ b/package.json @@ -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 scripts/pi-car-companion-power && python3 -c \"compile(open('scripts/pi-car-companion-bluetooth', encoding='utf-8').read(), 'scripts/pi-car-companion-bluetooth', 'exec')\"", + "check:scripts": "bash -n install.sh scripts/pi-car-companion-update scripts/pi-car-companion-network-setup scripts/pi-car-companion-power scripts/pi-car-companion-bluetooth-control && python3 -c \"compile(open('scripts/pi-car-companion-bluetooth', encoding='utf-8').read(), 'scripts/pi-car-companion-bluetooth', 'exec')\"", "check": "npm run check:scripts && npm run lint && npm run typecheck && npm test && npm run build" }, "devDependencies": { diff --git a/scripts/pi-car-companion-bluetooth-control b/scripts/pi-car-companion-bluetooth-control new file mode 100755 index 0000000..5052483 --- /dev/null +++ b/scripts/pi-car-companion-bluetooth-control @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "The Bluetooth control helper must run as root." >&2 + exit 1 +fi + +BLUETOOTHCTL=/usr/bin/bluetoothctl +SYSTEMCTL=/usr/bin/systemctl +BRIDGE_SERVICE=pi-car-companion-bluetooth.service + +print_status() { + local details powered discoverable pairable message + details="$(LC_ALL=C "${BLUETOOTHCTL}" show 2>/dev/null || true)" + if ! grep -q '^Controller ' <<<"${details}"; then + printf '%s\n' '{"supported":false,"powered":false,"discoverable":false,"pairable":false,"message":"No Bluetooth adapter was found."}' + return + fi + powered="$(sed -n 's/^[[:space:]]*Powered: //p' <<<"${details}" | head -n 1)" + discoverable="$(sed -n 's/^[[:space:]]*Discoverable: //p' <<<"${details}" | head -n 1)" + pairable="$(sed -n 's/^[[:space:]]*Pairable: //p' <<<"${details}" | head -n 1)" + [[ "${powered}" == "yes" ]] && powered=true || powered=false + [[ "${discoverable}" == "yes" ]] && discoverable=true || discoverable=false + [[ "${pairable}" == "yes" ]] && pairable=true || pairable=false + if [[ "${discoverable}" == "true" ]]; then + message="The Pi is visible to nearby phones for three minutes." + elif [[ "${powered}" == "true" ]]; then + message="Bluetooth is on and the companion bridge is available." + else + message="Bluetooth is off." + fi + printf '{"supported":true,"powered":%s,"discoverable":%s,"pairable":%s,"message":"%s"}\n' \ + "${powered}" "${discoverable}" "${pairable}" "${message}" +} + +power_on() { + "${SYSTEMCTL}" start bluetooth.service + /usr/sbin/rfkill unblock bluetooth + "${BLUETOOTHCTL}" power on >/dev/null + "${SYSTEMCTL}" enable --now "${BRIDGE_SERVICE}" >/dev/null +} + +case "${1:-}" in + status) + ;; + on) + power_on + ;; + off) + "${SYSTEMCTL}" disable --now "${BRIDGE_SERVICE}" >/dev/null + "${BLUETOOTHCTL}" discoverable off >/dev/null 2>&1 || true + "${BLUETOOTHCTL}" pairable off >/dev/null 2>&1 || true + "${BLUETOOTHCTL}" power off >/dev/null + /usr/sbin/rfkill block bluetooth + ;; + discoverable) + power_on + "${BLUETOOTHCTL}" discoverable-timeout 180 >/dev/null + "${BLUETOOTHCTL}" pairable-timeout 180 >/dev/null + "${BLUETOOTHCTL}" pairable on >/dev/null + "${BLUETOOTHCTL}" discoverable on >/dev/null + ;; + *) + echo "Usage: pi-car-companion-bluetooth-control status|on|off|discoverable" >&2 + exit 2 + ;; +esac + +print_status diff --git a/scripts/pi-car-companion-update b/scripts/pi-car-companion-update index bc4ded0..eef195f 100755 --- a/scripts/pi-car-companion-update +++ b/scripts/pi-car-companion-update @@ -66,12 +66,18 @@ install_integration_files() { 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 + if [[ -f "${source_release}/scripts/pi-car-companion-bluetooth-control" ]]; then + install -m 0755 "${source_release}/scripts/pi-car-companion-bluetooth-control" /usr/local/sbin/pi-car-companion-bluetooth-control + fi + install -d -m 0755 /usr/local/libexec + install -m 0755 "${source_release}/scripts/pi-car-companion-bluetooth" /usr/local/libexec/pi-car-companion-bluetooth 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 + install -m 0644 "${source_release}/systemd/pi-car-companion-bluetooth.service" /etc/systemd/system/pi-car-companion-bluetooth.service install -m 0440 "${source_release}/systemd/pi-car-companion.sudoers" /etc/sudoers.d/pi-car-companion install -d -m 0755 /etc/NetworkManager/dnsmasq-shared.d install -m 0644 "${source_release}/config/dnsmasq-shared-pi-car-companion.conf" /etc/NetworkManager/dnsmasq-shared.d/pi-car-companion.conf @@ -204,3 +210,4 @@ write_status "success" "Update installed and the companion service is healthy." systemctl try-restart pi-car-companion-network.service || true systemctl enable pi-car-companion-network-observer.service || true systemctl restart pi-car-companion-network-observer.service || true +systemctl try-restart pi-car-companion-bluetooth.service || true diff --git a/server/src/bluetooth.ts b/server/src/bluetooth.ts new file mode 100644 index 0000000..8bd9e40 --- /dev/null +++ b/server/src/bluetooth.ts @@ -0,0 +1,76 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { z } from "zod"; + +const execFileAsync = promisify(execFile); +const helperPath = "/usr/local/sbin/pi-car-companion-bluetooth-control"; + +export const bluetoothActionSchema = z.enum(["on", "off", "discoverable"]); +export type BluetoothAction = z.infer; + +export const bluetoothStatusSchema = z.object({ + supported: z.boolean(), + powered: z.boolean(), + discoverable: z.boolean(), + pairable: z.boolean(), + message: z.string() +}); +export type BluetoothStatus = z.infer; + +function unavailableStatus(): BluetoothStatus { + return { + supported: false, + powered: false, + discoverable: false, + pairable: false, + message: "Bluetooth controls are available after installing the Pi system service." + }; +} + +export function parseBluetoothStatus(output: string): BluetoothStatus { + return bluetoothStatusSchema.parse(JSON.parse(output)); +} + +export function parseBluetoothctlStatus(output: string): BluetoothStatus { + if (!/^Controller /m.test(output)) { + return { ...unavailableStatus(), message: "No Bluetooth adapter was found." }; + } + const property = (name: string) => new RegExp(`^\\s*${name}: yes$`, "m").test(output); + const powered = property("Powered"); + const discoverable = property("Discoverable"); + return { + supported: true, + powered, + discoverable, + pairable: property("Pairable"), + message: discoverable + ? "The Pi is visible to nearby phones for three minutes." + : powered + ? "Bluetooth is on and the companion bridge is available." + : "Bluetooth is off." + }; +} + +async function runHelper(action: BluetoothAction): Promise { + const { stdout } = await execFileAsync("/usr/bin/sudo", ["-n", helperPath, action], { + timeout: 12_000, + maxBuffer: 16 * 1024 + }); + return parseBluetoothStatus(stdout); +} + +export async function readBluetoothStatus(): Promise { + try { + const { stdout } = await execFileAsync("/usr/bin/bluetoothctl", ["show"], { + timeout: 5_000, + maxBuffer: 16 * 1024 + }); + return parseBluetoothctlStatus(stdout); + } catch { + return unavailableStatus(); + } +} + +export async function triggerBluetoothAction(action: BluetoothAction): Promise { + return runHelper(action); +} diff --git a/server/src/mobile.ts b/server/src/mobile.ts index 4a6c3bd..afcbe02 100644 --- a/server/src/mobile.ts +++ b/server/src/mobile.ts @@ -3,6 +3,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import type { AdbConfig } from "./adb.js"; import { launchAdbTarget } from "./adb.js"; +import { bluetoothActionSchema, readBluetoothStatus, triggerBluetoothAction } from "./bluetooth.js"; import type { CarTelemetryRecorder } from "./car-telemetry.js"; import type { CompanionDatabase } from "./database.js"; import { hashToken, randomToken } from "./security.js"; @@ -93,6 +94,26 @@ export function registerMobileRoutes( return { devices }; }); + app.get("/api/mobile/bluetooth", async (request, reply) => { + if (!requireWebUser(request, reply)) return; + return readBluetoothStatus(); + }); + + app.post("/api/mobile/bluetooth", { config: { rateLimit: { max: 12, timeWindow: "1 minute" } } }, async (request, reply) => { + if (!requireWebUser(request, reply)) return; + const parsed = z.object({ action: bluetoothActionSchema }).safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: { code: "INVALID_BLUETOOTH_ACTION", message: "That Bluetooth action is not available" } }); + } + try { + return await triggerBluetoothAction(parsed.data.action); + } catch { + return reply.code(503).send({ + error: { code: "BLUETOOTH_ACTION_FAILED", message: "The Pi could not change its Bluetooth state" } + }); + } + }); + app.delete<{ Params: { id: string } }>("/api/mobile/devices/:id", async (request, reply) => { if (!requireWebUser(request, reply)) return; const result = database.prepare("UPDATE mobile_devices SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL") diff --git a/server/tests/app.test.ts b/server/tests/app.test.ts index 1c8b3a2..6a0d983 100644 --- a/server/tests/app.test.ts +++ b/server/tests/app.test.ts @@ -356,6 +356,18 @@ describe("authentication boundary", () => { const webHeaders = { ...mutationHeaders(csrfToken), cookie }; expect((await app.inject({ method: "GET", url: "/api/mobile/devices" })).statusCode).toBe(401); + expect((await app.inject({ method: "GET", url: "/api/mobile/bluetooth" })).statusCode).toBe(401); + const bluetooth = await app.inject({ method: "GET", url: "/api/mobile/bluetooth", headers: { cookie } }); + expect(bluetooth.statusCode).toBe(200); + expect(bluetooth.json()).toMatchObject({ powered: expect.any(Boolean), discoverable: expect.any(Boolean) }); + const invalidBluetoothAction = await app.inject({ + method: "POST", + url: "/api/mobile/bluetooth", + headers: webHeaders, + payload: { action: "arbitrary-command" } + }); + expect(invalidBluetoothAction.statusCode).toBe(400); + expect(invalidBluetoothAction.json()).toMatchObject({ error: { code: "INVALID_BLUETOOTH_ACTION" } }); const pairing = await app.inject({ method: "POST", url: "/api/mobile/pairing", headers: webHeaders }); expect(pairing.statusCode).toBe(200); const code = pairing.json<{ code: string }>().code; diff --git a/server/tests/bluetooth.test.ts b/server/tests/bluetooth.test.ts new file mode 100644 index 0000000..b519375 --- /dev/null +++ b/server/tests/bluetooth.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { bluetoothActionSchema, parseBluetoothctlStatus, parseBluetoothStatus } from "../src/bluetooth.js"; + +describe("Bluetooth controls", () => { + it("accepts only fixed radio actions", () => { + expect(bluetoothActionSchema.safeParse("on").success).toBe(true); + expect(bluetoothActionSchema.safeParse("off").success).toBe(true); + expect(bluetoothActionSchema.safeParse("discoverable").success).toBe(true); + expect(bluetoothActionSchema.safeParse("scan --all").success).toBe(false); + }); + + it("validates status returned by the privileged helper", () => { + expect(parseBluetoothStatus(JSON.stringify({ + supported: true, + powered: true, + discoverable: false, + pairable: true, + message: "Bluetooth is on." + }))).toMatchObject({ supported: true, powered: true, discoverable: false }); + + expect(() => parseBluetoothStatus('{"powered":"yes"}')).toThrow(); + }); + + it("reads the adapter state from BlueZ without privileged polling", () => { + const status = parseBluetoothctlStatus(`Controller AA:BB:CC:DD:EE:FF Pi [default]\n\tPowered: yes\n\tDiscoverable: yes\n\tPairable: yes\n`); + expect(status).toMatchObject({ supported: true, powered: true, discoverable: true, pairable: true }); + expect(parseBluetoothctlStatus("")).toMatchObject({ supported: false, powered: false }); + }); +}); diff --git a/systemd/pi-car-companion.sudoers b/systemd/pi-car-companion.sudoers index f1d8c66..3860853 100644 --- a/systemd/pi-car-companion.sudoers +++ b/systemd/pi-car-companion.sudoers @@ -3,3 +3,6 @@ pi-companion ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pi-car-com 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 +pi-companion ALL=(root) NOPASSWD: /usr/local/sbin/pi-car-companion-bluetooth-control on +pi-companion ALL=(root) NOPASSWD: /usr/local/sbin/pi-car-companion-bluetooth-control off +pi-companion ALL=(root) NOPASSWD: /usr/local/sbin/pi-car-companion-bluetooth-control discoverable diff --git a/web/src/App.tsx b/web/src/App.tsx index cc274fc..35e33ea 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -29,6 +29,7 @@ import { getNetworkActivity, getUpdateStatus, getMobileDevices, + getBluetoothStatus, post, revokeMobileDevice, type AuthState, @@ -36,7 +37,8 @@ import { type NetworkStatus, type NetworkActivity, type UpdateStatus, - type MobileDevice + type MobileDevice, + type BluetoothStatus } from "./api"; const ShellTerminal = lazy(async () => { @@ -344,8 +346,10 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) { const [confirmHotspot, setConfirmHotspot] = useState(false); const [networkNotice, setNetworkNotice] = useState(""); const [mobileDevices, setMobileDevices] = useState([]); + const [bluetooth, setBluetooth] = useState(null); const [pairingCode, setPairingCode] = useState<{ code: string; expiresAt: string } | null>(null); const [mobileError, setMobileError] = useState(""); + const [mobileNotice, setMobileNotice] = useState(""); const running = update?.state === "checking" || update?.state === "building"; const refreshUpdate = useCallback(async () => { @@ -375,16 +379,43 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) { } }, []); + const refreshBluetooth = useCallback(async () => { + try { + setBluetooth(await getBluetoothStatus()); + } catch (caught) { + setMobileError(caught instanceof Error ? caught.message : "Bluetooth status could not be loaded"); + } + }, []); + useEffect(() => { void refreshUpdate(); void refreshNetwork(); void refreshMobile(); + void refreshBluetooth(); const interval = window.setInterval(() => { void refreshUpdate(); void refreshNetwork(); + void refreshBluetooth(); }, 3_000); return () => window.clearInterval(interval); - }, [refreshMobile, refreshNetwork, refreshUpdate]); + }, [refreshBluetooth, refreshMobile, refreshNetwork, refreshUpdate]); + + async function requestBluetoothAction(action: "on" | "off" | "discoverable") { + setRequesting(true); + setMobileError(""); + setMobileNotice(""); + try { + const status = await post("/api/mobile/bluetooth", { action }, csrfToken); + setBluetooth(status); + setMobileNotice(action === "discoverable" + ? "The Pi is discoverable to nearby phones for three minutes." + : `Bluetooth turned ${action}.`); + } catch (caught) { + setMobileError(caught instanceof Error ? caught.message : "Bluetooth could not be changed"); + } finally { + setRequesting(false); + } + } async function createPairingCode() { setRequesting(true); @@ -662,6 +693,25 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {

Android companion

Pair the phone app over Bluetooth to receive live Pi and car status and contribute phone GPS.

{mobileError &&
{mobileError}
} + {mobileNotice &&
{mobileNotice}
} +
+
+ +
+ {!bluetooth ? "Checking Bluetooth…" : !bluetooth.supported ? "Bluetooth unavailable" : bluetooth.powered ? "Bluetooth on" : "Bluetooth off"} + {bluetooth?.message ?? "Reading the Pi radio state."} +
+ {bluetooth?.discoverable && Discoverable} +
+
+ + +
+
{pairingCode ?
Enter this code in the Android app {pairingCode.code} diff --git a/web/src/api.ts b/web/src/api.ts index 3e6f5ef..4e1c579 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -122,6 +122,14 @@ export type MobileDevice = { revokedAt: string | null; }; +export type BluetoothStatus = { + supported: boolean; + powered: boolean; + discoverable: boolean; + pairable: boolean; + message: string; +}; + export type CarTelemetry = { available: true; collectedAt: string; @@ -262,6 +270,10 @@ export async function getMobileDevices(): Promise { return response.devices; } +export async function getBluetoothStatus(): Promise { + return parseResponse(await fetch("/api/mobile/bluetooth", { credentials: "same-origin" })); +} + export async function revokeMobileDevice(id: string, csrfToken: string): Promise { await parseResponse(await fetch(`/api/mobile/devices/${encodeURIComponent(id)}`, { method: "DELETE", diff --git a/web/src/styles.css b/web/src/styles.css index 0e014a3..c688c70 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -139,6 +139,13 @@ button:disabled { opacity: 0.55; cursor: not-allowed; } .settings-page { min-width: 0; width: 100%; display: grid; gap: 18px; } .mobile-settings-layout { grid-template-columns: minmax(340px, 0.8fr) minmax(420px, 1.2fr); } +.bluetooth-control { display: grid; gap: 14px; margin: 22px 0; padding: 17px; background: #10140f; border: 1px solid var(--line); border-radius: 11px; } +.bluetooth-state { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; } +.bluetooth-state > div { min-width: 0; display: grid; gap: 4px; } +.bluetooth-state small { overflow: hidden; color: var(--muted); font-size: 0.74rem; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } +.bluetooth-visible { padding: 5px 8px; color: var(--accent); background: rgba(185, 231, 105, 0.08); border: 1px solid rgba(185, 231, 105, 0.22); border-radius: 999px; font-size: 0.66rem; font-weight: 750; text-transform: uppercase; } +.bluetooth-actions { display: flex; flex-wrap: wrap; gap: 9px; } +.bluetooth-actions button { min-height: 44px; } .pairing-code { display: grid; justify-items: center; gap: 8px; margin: 28px 0 20px; padding: 24px; background: #10140f; border: 1px solid var(--line); border-radius: 12px; } .pairing-code span, .pairing-code small { color: var(--muted); font-size: 0.78rem; } .pairing-code strong { color: var(--accent); font: 800 2.7rem/1 ui-monospace, "Cascadia Code", monospace; letter-spacing: 0.18em; } @@ -273,6 +280,10 @@ button:disabled { opacity: 0.55; cursor: not-allowed; } .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; } + .bluetooth-actions { flex-direction: column; } + .bluetooth-actions button { width: 100%; padding: 0 18px; font-size: inherit; } + .bluetooth-state { grid-template-columns: auto minmax(0, 1fr); } + .bluetooth-visible { grid-column: 2; justify-self: start; } .terminal-host { height: 58dvh; min-height: 320px; } .skeleton-grid { grid-template-columns: 1fr; } }