import { SupportBadge } from "@app/components/Badge/SupportedBadge.tsx"; import { Switch } from "@app/components/UI/Switch.tsx"; import type { NewConnection } from "@app/core/stores/deviceStore/types.ts"; import { testHttpReachable } from "@app/pages/Connections/utils"; import { Button } from "@components/UI/Button.tsx"; import { Input } from "@components/UI/Input.tsx"; import { Label } from "@components/UI/Label.tsx"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@components/UI/Tabs.tsx"; import { Link } from "@components/UI/Typography/Link.tsx"; import { type BrowserFeature, useBrowserFeatureDetection, } from "@core/hooks/useBrowserFeatureDetection.ts"; import { useToast } from "@core/hooks/useToast.ts"; import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth"; import { AlertCircle, Bluetooth, Cable, CheckCircle2, Globe, Loader2, type LucideIcon, MousePointerClick, XCircle, } from "lucide-react"; import { useCallback, useEffect, useMemo, useReducer } from "react"; import { Trans, useTranslation } from "react-i18next"; import { DialogWrapper } from "../DialogWrapper.tsx"; import { urlOrIpv4Schema } from "./validation.ts"; type TabKey = "http" | "bluetooth" | "serial"; type TestingStatus = "idle" | "testing" | "success" | "failure"; type DialogState = { tab: TabKey; name: string; protocol: "http" | "https"; url: string; testStatus: TestingStatus; btSelected: | { id: string; name?: string; device?: BluetoothDevice } | undefined; serialSelected: { vendorId?: number; productId?: number } | undefined; }; type DialogAction = | { type: "RESET"; payload?: { isHTTPS?: boolean } } | { type: "SET_TAB"; payload: TabKey } | { type: "SET_NAME"; payload: string } | { type: "SET_PROTOCOL"; payload: "http" | "https" } | { type: "SET_URL"; payload: string } | { type: "SET_TEST_STATUS"; payload: TestingStatus } | { type: "SET_BT_SELECTED"; payload: | { id: string; name?: string; device?: BluetoothDevice } | undefined; } | { type: "SET_SERIAL_SELECTED"; payload: { vendorId?: number; productId?: number } | undefined; } | { type: "SET_URL_AND_RESET_TEST"; payload: string }; interface FeatureErrorProps { missingFeatures: BrowserFeature[]; tabId: "bluetooth" | "serial"; } type Pane = { children: () => React.ReactNode; placeholder: string; validate: () => boolean; build: () => NewConnection | null; }; const featureErrors: Record = { "Web Bluetooth": { href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility", i18nKey: "addConnection.validation.requiresWebBluetooth", }, "Web Serial": { href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility", i18nKey: "addConnection.validation.requiresWebSerial", }, "Secure Context": { href: "https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts", i18nKey: "addConnection.validation.requiresSecureContext", }, }; const FeatureErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => { if (missingFeatures.length === 0) { return null; } const browserFeatures = missingFeatures.filter( (feature) => feature !== "Secure Context", ); const needsSecureContext = missingFeatures.includes("Secure Context"); const needsFeature = tabId === "bluetooth" && browserFeatures.includes("Web Bluetooth") ? "Web Bluetooth" : tabId === "serial" && browserFeatures.includes("Web Serial") ? "Web Serial" : undefined; return (
{needsFeature && ( , ]} /> )} {needsFeature && needsSecureContext && " "} {needsSecureContext && ( 0 ? "addConnection.validation.additionallyRequiresSecureContext" : "addConnection.validation.requiresSecureContext" } components={{ "0": ( ), }} /> )}
); }; const initialState: DialogState = { tab: "http", name: "", protocol: "http", url: "", testStatus: "idle", btSelected: undefined, serialSelected: undefined, }; export const createInitialDialogState = ( overrides?: Partial, ): DialogState => { return { ...initialState, ...(overrides ?? {}) }; }; export const dialogStateInitializer = ( overrides?: Partial, ): DialogState => createInitialDialogState(overrides); function dialogReducer(state: DialogState, action: DialogAction): DialogState { switch (action.type) { case "RESET": return createInitialDialogState( action.payload?.isHTTPS ? { protocol: "https" } : {}, ); case "SET_TAB": return { ...state, tab: action.payload }; case "SET_NAME": return { ...state, name: action.payload }; case "SET_PROTOCOL": return { ...state, protocol: action.payload }; case "SET_URL": return { ...state, url: action.payload }; case "SET_TEST_STATUS": return { ...state, testStatus: action.payload }; case "SET_BT_SELECTED": return { ...state, btSelected: action.payload }; case "SET_SERIAL_SELECTED": return { ...state, serialSelected: action.payload }; case "SET_URL_AND_RESET_TEST": return { ...state, url: action.payload, testStatus: "idle" }; default: return state; } } function PickerRow({ label, buttonText, onPick, disabled, display, helper, }: { label: string; buttonText: string; onPick: () => void | Promise; disabled?: boolean; display: string; helper?: string; }) { return (
{display}
{helper ? (

{helper}

) : null}
); } const TAB_META: Array<{ key: TabKey; label: string; Icon: LucideIcon }> = [ { key: "http", label: "HTTP", Icon: Globe }, { key: "bluetooth", label: "Bluetooth", Icon: Bluetooth }, { key: "serial", label: "Serial", Icon: Cable }, ]; export default function AddConnectionDialog({ open = false, onOpenChange = () => {}, onSave = async () => {}, isHTTPS = false, }: { open?: boolean; onOpenChange?: (open: boolean) => void; onSave?: (conn: NewConnection, device?: BluetoothDevice) => Promise; isHTTPS?: boolean; }) { const { toast } = useToast(); const [state, dispatch] = useReducer(dialogReducer, initialState, () => dialogStateInitializer(isHTTPS ? { protocol: "https" } : {}), ); const { unsupported } = useBrowserFeatureDetection(); const { t } = useTranslation(); const bluetoothSupported = typeof navigator !== "undefined" && "bluetooth" in navigator; const serialSupported = typeof navigator !== "undefined" && "serial" in navigator; const isURLHTTPS = isHTTPS; const reset = useCallback(() => { dispatch({ type: "RESET", payload: { isHTTPS } }); }, [isHTTPS]); useEffect(() => { if (!open) { reset(); } }, [reset, open]); const makeToastErrorHandler = useCallback( (titlePrefix: string) => (err: unknown): void => { if (err && (err as { name?: string }).name === "NotFoundError") { return; // user canceled } const message = err instanceof Error ? err.message : String(err); toast({ title: `${titlePrefix} error`, description: message }); }, [toast], ); const handlePickBluetooth = useCallback(async () => { if (!bluetoothSupported) { toast({ title: t("addConnection.bluetoothConnection.notSupported.title"), description: t( "addConnection.bluetoothConnection.notSupported.description", ), }); return; } try { const device = await navigator.bluetooth.requestDevice({ filters: [{ services: [TransportWebBluetooth.ServiceUuid] }], }); dispatch({ type: "SET_BT_SELECTED", payload: { id: device.id, name: device.name ?? undefined, device }, }); if (!state.name || state.name === "") { dispatch({ type: "SET_NAME", payload: device.name ? t("addConnection.bluetoothConnection.short", { deviceName: device.name, }) : t("addConnection.bluetoothConnection.long"), }); } toast({ title: t("addConnection.bluetoothConnection.selected"), description: device.name || device.id, }); } catch (err) { makeToastErrorHandler("Bluetooth")(err); } }, [bluetoothSupported, state.name, toast, makeToastErrorHandler, t]); const handlePickSerial = useCallback(async () => { if (!serialSupported) { toast({ title: t("addConnection.serialConnection.notSupported.title"), description: t( "addConnection.serialConnection.notSupported.description", ), }); return; } try { const port = await ( navigator as Navigator & { serial: { requestPort: (o: Record) => Promise; }; } ).serial.requestPort({}); const info = ( port as SerialPort & { getInfo?: () => { usbVendorId?: number; usbProductId?: number }; } ).getInfo?.() ?? {}; dispatch({ type: "SET_SERIAL_SELECTED", payload: { vendorId: info.usbVendorId, productId: info.usbProductId, }, }); if (!state.name || state.name === "") { const v = info.usbVendorId ? info.usbVendorId.toString(16) : "?"; const p = info.usbProductId ? info.usbProductId.toString(16) : "?"; dispatch({ type: "SET_NAME", payload: `Serial: ${v}:${p}` }); } toast({ title: t("addConnection.serialConnection.portSelected.title"), description: t( "addConnection.serialConnection.portSelected.description", ), }); } catch (err) { makeToastErrorHandler("Serial")(err); } }, [serialSupported, state.name, toast, makeToastErrorHandler, t]); const handleTestHttp = useCallback(async () => { const fullUrl = `${state.protocol}://${state.url}`; const validatedURL = urlOrIpv4Schema.safeParse(fullUrl); if (validatedURL.success === false) { toast({ title: t("addConnection.httpConnection.invalidUrl.title"), description: t("addConnection.httpConnection.invalidUrl.description"), }); return; } dispatch({ type: "SET_TEST_STATUS", payload: "testing" }); const reachable = await testHttpReachable(validatedURL.data); if (reachable) { dispatch({ type: "SET_TEST_STATUS", payload: "success" }); toast({ title: t("addConnection.httpConnection.connectionTest.success.title"), description: t( "addConnection.httpConnection.connectionTest.success.description", ), }); } else { dispatch({ type: "SET_TEST_STATUS", payload: "failure" }); toast({ title: t("addConnection.httpConnection.connectionTest.failure.title"), description: t( "addConnection.httpConnection.connectionTest.failure.description", ), }); } }, [state.protocol, state.url, toast, t]); const PANES: Record = useMemo( () => ({ http: { placeholder: t("addConnection.httpConnection.namePlaceholder"), children: () => (
{ dispatch({ type: "SET_URL_AND_RESET_TEST", payload: e.target.value, }); }} />
{ dispatch({ type: "SET_PROTOCOL", payload: value ? "https" : "http", }); dispatch({ type: "SET_TEST_STATUS", payload: "idle" }); }} >
{state.testStatus === "success" && (
{t("addConnection.httpConnection.connectionTest.reachable")}
)} {state.testStatus === "failure" && (
{t( "addConnection.httpConnection.connectionTest.notReachable", )}
)}

{t("addConnection.httpConnection.connectionTest.description")}

), validate: () => urlOrIpv4Schema.safeParse(`${state.protocol}://${state.url}`) .success === true && state.testStatus === "success", build: () => ({ type: "http", name: state.name.trim(), url: `${state.protocol}://${state.url.trim()}`, }), }, bluetooth: { placeholder: "My Bluetooth Node", children: () => ( <> ), validate: () => state.name.trim().length > 0 && !!state.btSelected, build: () => ({ type: "bluetooth", name: state.name.trim(), deviceId: state.btSelected?.id, deviceName: state.btSelected?.name, gattServiceUUID: TransportWebBluetooth.ServiceUuid, }), }, serial: { placeholder: t("addConnection.serialConnection.namePlaceholder"), children: () => ( <> ), validate: () => state.name.trim().length > 0 && (!!state.serialSelected || !serialSupported), build: () => ({ type: "serial", name: state.name.trim(), usbVendorId: state.serialSelected?.vendorId, usbProductId: state.serialSelected?.productId, }), }, }), [ state, bluetoothSupported, serialSupported, isURLHTTPS, handlePickBluetooth, handlePickSerial, handleTestHttp, unsupported, t, ], ); const currentPane = PANES[state.tab]; const canCreate = useMemo(() => currentPane.validate(), [currentPane]); const submit = (fn: (p: NewConnection, device?: BluetoothDevice) => Promise) => async () => { if (!canCreate) { return; } const payload = currentPane.build(); if (!payload) { return; } const btDevice = state.tab === "bluetooth" ? state.btSelected?.device : undefined; await fn(payload, btDevice); }; return ( dispatch({ type: "SET_TAB", payload: v as TabKey }) } > {TAB_META.map(({ key, label, Icon }) => ( {label} ))} {TAB_META.map(({ key }) => ( {state.tab === key ? (
dispatch({ type: "SET_NAME", payload: evt.target.value }) } placeholder={currentPane.placeholder} />
{PANES[key].children()}
) : null}
))}
); }