feat: add Bluetooth Android companion
This commit is contained in:
+73
-3
@@ -2,6 +2,7 @@ import { lazy, Suspense, useCallback, useEffect, useState, type FormEvent } from
|
||||
import {
|
||||
ArrowClockwise,
|
||||
AndroidLogo,
|
||||
Bluetooth,
|
||||
CarProfile,
|
||||
CheckCircle,
|
||||
Cpu,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
Power,
|
||||
SignOut,
|
||||
TerminalWindow,
|
||||
Trash,
|
||||
WarningCircle,
|
||||
WifiHigh
|
||||
} from "@phosphor-icons/react";
|
||||
@@ -26,12 +28,15 @@ import {
|
||||
getNetworkStatus,
|
||||
getNetworkActivity,
|
||||
getUpdateStatus,
|
||||
getMobileDevices,
|
||||
post,
|
||||
revokeMobileDevice,
|
||||
type AuthState,
|
||||
type SystemStatus,
|
||||
type NetworkStatus,
|
||||
type NetworkActivity,
|
||||
type UpdateStatus
|
||||
type UpdateStatus,
|
||||
type MobileDevice
|
||||
} from "./api";
|
||||
|
||||
const ShellTerminal = lazy(async () => {
|
||||
@@ -322,7 +327,7 @@ function portLabel(port: number): string {
|
||||
}
|
||||
|
||||
function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||
const [tab, setTab] = useState<"network" | "system" | "shell">("network");
|
||||
const [tab, setTab] = useState<"network" | "system" | "mobile" | "shell">("network");
|
||||
const [update, setUpdate] = useState<UpdateStatus | null>(null);
|
||||
const [network, setNetwork] = useState<NetworkStatus | null>(null);
|
||||
const [updateError, setUpdateError] = useState("");
|
||||
@@ -338,6 +343,9 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||
const [ssidTouched, setSsidTouched] = useState(false);
|
||||
const [confirmHotspot, setConfirmHotspot] = useState(false);
|
||||
const [networkNotice, setNetworkNotice] = useState("");
|
||||
const [mobileDevices, setMobileDevices] = useState<MobileDevice[]>([]);
|
||||
const [pairingCode, setPairingCode] = useState<{ code: string; expiresAt: string } | null>(null);
|
||||
const [mobileError, setMobileError] = useState("");
|
||||
const running = update?.state === "checking" || update?.state === "building";
|
||||
|
||||
const refreshUpdate = useCallback(async () => {
|
||||
@@ -358,15 +366,49 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshMobile = useCallback(async () => {
|
||||
try {
|
||||
setMobileDevices(await getMobileDevices());
|
||||
setMobileError("");
|
||||
} catch (caught) {
|
||||
setMobileError(caught instanceof Error ? caught.message : "Paired phones could not be loaded");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshUpdate();
|
||||
void refreshNetwork();
|
||||
void refreshMobile();
|
||||
const interval = window.setInterval(() => {
|
||||
void refreshUpdate();
|
||||
void refreshNetwork();
|
||||
}, 3_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [refreshNetwork, refreshUpdate]);
|
||||
}, [refreshMobile, refreshNetwork, refreshUpdate]);
|
||||
|
||||
async function createPairingCode() {
|
||||
setRequesting(true);
|
||||
setMobileError("");
|
||||
try {
|
||||
setPairingCode(await post<{ code: string; expiresAt: string }>("/api/mobile/pairing", {}, csrfToken));
|
||||
} catch (caught) {
|
||||
setMobileError(caught instanceof Error ? caught.message : "A pairing code could not be created");
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokePhone(device: MobileDevice) {
|
||||
setRequesting(true);
|
||||
try {
|
||||
await revokeMobileDevice(device.id, csrfToken);
|
||||
await refreshMobile();
|
||||
} catch (caught) {
|
||||
setMobileError(caught instanceof Error ? caught.message : "The phone could not be revoked");
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!ssidTouched && network?.hotspotSsid) setHotspotSsid(network.hotspotSsid);
|
||||
@@ -489,6 +531,7 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||
<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 === "mobile"} className={tab === "mobile" ? "active" : ""} onClick={() => setTab("mobile")}><Bluetooth size={20} />Mobile</button>
|
||||
<button role="tab" aria-selected={tab === "shell"} className={tab === "shell" ? "active" : ""} onClick={() => setTab("shell")}><TerminalWindow size={20} />Shell</button>
|
||||
</nav>
|
||||
|
||||
@@ -612,6 +655,33 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||
</section>
|
||||
</div>}
|
||||
|
||||
{tab === "mobile" && <div className="settings-layout mobile-settings-layout" role="tabpanel">
|
||||
<section className="settings-panel mobile-pairing-panel">
|
||||
<div className="settings-title">
|
||||
<div className="settings-icon"><Bluetooth size={30} weight="duotone" /></div>
|
||||
<div><h2>Android companion</h2><p>Pair the phone app over Bluetooth to receive live Pi and car status and contribute phone GPS.</p></div>
|
||||
</div>
|
||||
{mobileError && <div className="form-error" role="alert"><WarningCircle size={20} />{mobileError}</div>}
|
||||
{pairingCode ? <div className="pairing-code" role="status">
|
||||
<span>Enter this code in the Android app</span>
|
||||
<strong>{pairingCode.code}</strong>
|
||||
<small>Expires {new Date(pairingCode.expiresAt).toLocaleTimeString()}</small>
|
||||
</div> : <p className="settings-note">Codes work once and expire after ten minutes. The phone receives a revocable credential stored in Android Keystore.</p>}
|
||||
<button className="primary-button" onClick={() => void createPairingCode()} disabled={requesting}><Bluetooth size={22} />{pairingCode ? "Generate another code" : "Pair a phone"}</button>
|
||||
</section>
|
||||
<section className="settings-panel mobile-devices-panel">
|
||||
<div className="panel-heading"><div><h2>Paired phones</h2><p>Revoke a phone immediately if it is lost or replaced.</p></div></div>
|
||||
<div className="mobile-device-list">
|
||||
{mobileDevices.filter((device) => !device.revokedAt).map((device) => <div className="mobile-device" key={device.id}>
|
||||
<Bluetooth size={22} />
|
||||
<div><strong>{device.name}</strong><small>Last seen {new Date(device.lastSeenAt).toLocaleString()}</small></div>
|
||||
<button className="icon-button" aria-label={`Revoke ${device.name}`} onClick={() => void revokePhone(device)} disabled={requesting}><Trash size={19} /></button>
|
||||
</div>)}
|
||||
{mobileDevices.every((device) => device.revokedAt) && <div className="activity-empty">No phones are paired yet.</div>}
|
||||
</div>
|
||||
</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)}>
|
||||
|
||||
@@ -114,6 +114,14 @@ export type AdbShortcut = AdbTarget & {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type MobileDevice = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
lastSeenAt: string;
|
||||
revokedAt: string | null;
|
||||
};
|
||||
|
||||
export type CarTelemetry = {
|
||||
available: true;
|
||||
collectedAt: string;
|
||||
@@ -247,6 +255,21 @@ export async function getAdbShortcuts(): Promise<AdbShortcut[]> {
|
||||
return response.shortcuts;
|
||||
}
|
||||
|
||||
export async function getMobileDevices(): Promise<MobileDevice[]> {
|
||||
const response = await parseResponse<{ devices: MobileDevice[] }>(
|
||||
await fetch("/api/mobile/devices", { credentials: "same-origin" })
|
||||
);
|
||||
return response.devices;
|
||||
}
|
||||
|
||||
export async function revokeMobileDevice(id: string, csrfToken: string): Promise<void> {
|
||||
await parseResponse(await fetch(`/api/mobile/devices/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
headers: { "X-CSRF-Token": csrfToken }
|
||||
}));
|
||||
}
|
||||
|
||||
export async function uploadApk(file: File, csrfToken: string): Promise<void> {
|
||||
await parseResponse(
|
||||
await fetch("/api/adb/install", {
|
||||
|
||||
@@ -138,6 +138,15 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.activity-privacy { margin: 0; color: var(--muted); font-size: 0.72rem; text-align: right; }
|
||||
|
||||
.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); }
|
||||
.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; }
|
||||
.mobile-device-list { display: grid; gap: 9px; margin-top: 22px; }
|
||||
.mobile-device { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 13px; min-height: 65px; padding: 10px 12px; background: #121610; border-radius: 10px; }
|
||||
.mobile-device > svg { color: var(--accent); }
|
||||
.mobile-device div { min-width: 0; display: grid; gap: 4px; }
|
||||
.mobile-device small { overflow: hidden; color: var(--muted); font-size: 0.72rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.settings-page > [role="tabpanel"] { min-width: 0; width: 100%; max-width: 100%; }
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user