diff --git a/.gitignore b/.gitignore index 8d28e352..c6da962b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,5 @@ stats.html .vite dev-dist __screenshots__* -*.diff npm/ - +*.diff diff --git a/deno.json b/deno.json index 71604d00..d6945d18 100644 --- a/deno.json +++ b/deno.json @@ -47,4 +47,5 @@ "out", ".vscode-test" ] + } diff --git a/deno.lock b/deno.lock index cddc5bc6..d9a1794b 100644 --- a/deno.lock +++ b/deno.lock @@ -104,6 +104,7 @@ "npm:testing-library@^0.0.2": "0.0.2_@angular+common@6.1.10__@angular+core@6.1.10___rxjs@6.6.7___zone.js@0.8.29__rxjs@6.6.7_@angular+core@6.1.10__rxjs@6.6.7__zone.js@0.8.29", "npm:tslog@^4.9.3": "4.9.3", "npm:typescript@^5.8.3": "5.8.3", + "npm:vite@*": "7.0.2_@types+node@22.16.0_picomatch@4.0.2", "npm:vite@7": "7.0.2_@types+node@22.16.0_picomatch@4.0.2", "npm:vitest@^3.2.4": "3.2.4_@types+node@22.16.0_happy-dom@18.0.1_vite@7.0.2__@types+node@22.16.0__picomatch@4.0.2", "npm:zod@^3.25.75": "3.25.75", diff --git a/packages/web/public/i18n/locales/en/dialog.json b/packages/web/public/i18n/locales/en/dialog.json index 56fb372b..7e19f8b5 100644 --- a/packages/web/public/i18n/locales/en/dialog.json +++ b/packages/web/public/i18n/locales/en/dialog.json @@ -79,6 +79,46 @@ "requiresFeatures": "This connection type requires <0>. Please use a supported browser, like Chrome or Edge.", "requiresSecureContext": "This application requires a <0>secure context. Please connect using HTTPS or localhost.", "additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context. Please connect using HTTPS or localhost." + }, + "tabs": { + "http": { + "title": "HTTP Servers", + "clearAll": "Clear All", + "noServers": "No HTTP servers added yet", + "addFirstServer": "Add your first Meshtastic server to get started", + "addNewDevice": "Add New Device", + "addServer": "Add HTTP Server", + "editServer": "Edit HTTP Server" + }, + "serial": { + "title": "Serial Devices", + "adding": "Adding...", + "addDevice": "Add Serial Device", + "noDevices": "No serial devices connected", + "connectFirst": "Connect your first Meshtastic device via USB" + }, + "bluetooth": { + "title": "Bluetooth Devices", + "pairing": "Pairing...", + "pairDevice": "Pair New Device", + "noDevices": "No Bluetooth devices paired", + "pairFirst": "Pair your first Meshtastic device" + }, + "status": { + "online": "Online", + "offline": "Offline", + "checking": "Checking...", + "connecting": "Connecting...", + "connected": "Connected", + "available": "Available", + "paired": "Paired", + "unknown": "Unknown" + }, + "actions": { + "connect": "Connect", + "cancel": "Cancel", + "saveChanges": "Save Changes" + } } }, "nodeDetails": { diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 78c43813..679f25a2 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,6 +1,5 @@ import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; import { DialogManager } from "@components/Dialog/DialogManager.tsx"; -import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx"; import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx"; import { Toaster } from "@components/Toaster.tsx"; import Footer from "@components/UI/Footer.tsx"; @@ -13,27 +12,30 @@ import { MapProvider } from "react-map-gl/maplibre"; import { CommandPalette } from "@components/CommandPalette/index.tsx"; import { SidebarProvider } from "@core/stores/sidebarStore.tsx"; import { useTheme } from "@core/hooks/useTheme.ts"; -import { Outlet } from "@tanstack/react-router"; +import { Outlet, useLocation, useNavigate } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; +import { useEffect } from "react"; export function App() { const { getDevice } = useDeviceStore(); - const { selectedDevice, setConnectDialogOpen, connectDialogOpen } = - useAppStore(); + const { selectedDevice } = useAppStore(); + const navigate = useNavigate(); + const location = useLocation(); const device = getDevice(selectedDevice); // Sets up light/dark mode based on user preferences or system settings useTheme(); + // Redirect to messages when a device connects and we're on the dashboard + useEffect(() => { + if (device && location.pathname === "/") { + navigate({ to: "/messages/broadcast/0", replace: true }); + } + }, [device, location.pathname, navigate]); + return ( - { - setConnectDialogOpen(open); - }} - /> diff --git a/packages/web/src/components/ConnectionTabs/ConnectionTabs.tsx b/packages/web/src/components/ConnectionTabs/ConnectionTabs.tsx new file mode 100644 index 00000000..0124a3a9 --- /dev/null +++ b/packages/web/src/components/ConnectionTabs/ConnectionTabs.tsx @@ -0,0 +1,61 @@ +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@components/UI/Tabs.tsx"; +import { HTTPTab } from "@components/PageComponents/Connect/Tabs/HTTPTab.tsx"; +import { BluetoothTab } from "@components/PageComponents/Connect/Tabs/BluetoothTab.tsx"; +import { SerialTab } from "@components/PageComponents/Connect/Tabs/SerialTab.tsx"; +import { Bluetooth, Server, Usb } from "lucide-react"; + +interface ConnectionTabsProps { + closeDialog?: () => void; + className?: string; +} + +export const ConnectionTabs = ( + { closeDialog, className }: ConnectionTabsProps, +) => { + const handleClose = () => { + if (closeDialog) { + closeDialog(); + } + }; + + return ( + + + + + HTTP + + + + Bluetooth + + + + Serial + + + +
+ + + + + + + + + + + +
+
+ ); +}; diff --git a/packages/web/src/components/Dashboard/BluetoothSection.tsx b/packages/web/src/components/Dashboard/BluetoothSection.tsx new file mode 100644 index 00000000..19a9605f --- /dev/null +++ b/packages/web/src/components/Dashboard/BluetoothSection.tsx @@ -0,0 +1,224 @@ +import { useCallback, useEffect, useState } from "react"; +import { Button } from "@components/UI/Button.tsx"; +import { useAppStore } from "@core/stores/appStore.ts"; +import { useDeviceStore } from "@core/stores/deviceStore.ts"; +import { subscribeAll } from "@core/subscriptions.ts"; +import { randId } from "@core/utils/randId.ts"; +import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth"; +import { MeshDevice } from "@meshtastic/core"; +import { + AlertTriangle, + Bluetooth, + Circle, + Clock, + Plus, + Trash2, +} from "lucide-react"; +import { useMessageStore } from "@core/stores/messageStore/index.ts"; + +interface BluetoothSectionProps { + onConnect?: () => void; +} + +export const BluetoothSection = ({ onConnect }: BluetoothSectionProps) => { + const [connectionInProgress, setConnectionInProgress] = useState(false); + const [connectingToDevice, setConnectingToDevice] = useState( + null, + ); + const [bleDevices, setBleDevices] = useState([]); + const [connectionError, setConnectionError] = useState(null); + + const { addDevice } = useDeviceStore(); + const messageStore = useMessageStore(); + const { setSelectedDevice } = useAppStore(); + + const updateBleDeviceList = useCallback(async (): Promise => { + try { + setBleDevices(await navigator.bluetooth.getDevices()); + } catch (error) { + console.error("Error getting Bluetooth devices:", error); + } + }, []); + + useEffect(() => { + updateBleDeviceList(); + }, [updateBleDeviceList]); + + const connectToDevice = async (bleDevice: BluetoothDevice) => { + setConnectingToDevice(bleDevice.id); + setConnectionError(null); + + try { + const id = randId(); + const transport = await TransportWebBluetooth.createFromDevice(bleDevice); + const device = addDevice(id); + const connection = new MeshDevice(transport, id); + connection.configure(); + setSelectedDevice(id); + device.addConnection(connection); + subscribeAll(device, connection, messageStore); + + onConnect?.(); + } catch (error) { + console.error("Bluetooth connection error:", error); + setConnectionError(`Failed to connect to ${bleDevice.name ?? "device"}`); + } finally { + setConnectingToDevice(null); + } + }; + + const handlePairNewDevice = async () => { + setConnectionInProgress(true); + setConnectionError(null); + + try { + const device = await navigator.bluetooth.requestDevice({ + filters: [{ services: [TransportWebBluetooth.ServiceUuid] }], + }); + + const exists = bleDevices.findIndex((d) => d.id === device.id); + if (exists === -1) { + setBleDevices([...bleDevices, device]); + } + } catch (error) { + console.error("Error pairing device:", error); + if (error instanceof Error && !error.message.includes("cancelled")) { + setConnectionError("Failed to pair new device"); + } + } finally { + setConnectionInProgress(false); + } + }; + + const removeBleDevice = (deviceId: string) => { + setBleDevices(bleDevices.filter((d) => d.id !== deviceId)); + }; + + const getStatusIcon = (device: BluetoothDevice) => { + const isConnecting = connectingToDevice === device.id; + const isConnected = device.gatt?.connected; + + if (isConnecting) { + return ( + + ); + } + if (isConnected) { + return ; + } + return ; + }; + + const getStatusText = (device: BluetoothDevice) => { + if (connectingToDevice === device.id) return "Connecting..."; + return device.gatt?.connected ? "Connected" : "Paired"; + }; + + return ( +
+ {/* Header */} +
+ +

+ Bluetooth Devices +

+
+ + {/* Device List */} +
+ {bleDevices.length === 0 + ? ( +
+ +

No Bluetooth devices paired

+
+ ) + : ( + bleDevices.map((device) => ( +
+ {/* Status */} +
+ {getStatusIcon(device)} +
+ + {/* Device Info */} +
+
+ + {device.name ?? "Unknown Device"} + + +
+ +
+ {getStatusText(device)} +
+
+ + {/* Actions */} +
+ + + +
+
+ )) + )} + + {/* Pair New Device Button */} + + + {/* Connection Error */} + {connectionError && ( +
+
+ +

+ {connectionError} +

+
+
+ )} +
+
+ ); +}; diff --git a/packages/web/src/components/Dashboard/HTTPServerSection.tsx b/packages/web/src/components/Dashboard/HTTPServerSection.tsx new file mode 100644 index 00000000..76c3cecc --- /dev/null +++ b/packages/web/src/components/Dashboard/HTTPServerSection.tsx @@ -0,0 +1,307 @@ +import { useState } from "react"; +import { Button } from "@components/UI/Button.tsx"; +import { Input } from "@components/UI/Input.tsx"; +import { Label } from "@components/UI/Label.tsx"; +import { Switch } from "@components/UI/Switch.tsx"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@components/UI/Dialog.tsx"; +import { useAppStore } from "@core/stores/appStore.ts"; +import { useDeviceStore } from "@core/stores/deviceStore.ts"; +import { subscribeAll } from "@core/subscriptions.ts"; +import { randId } from "@core/utils/randId.ts"; +import { MeshDevice } from "@meshtastic/core"; +import { TransportHTTP } from "@meshtastic/transport-http"; +import { useForm } from "react-hook-form"; +import { + AlertTriangle, + Clock, + Lock, + LockOpen, + Plus, + Server, + Trash2, + Wifi, +} from "lucide-react"; +import { useMessageStore } from "@core/stores/messageStore/index.ts"; +import type { SavedServer } from "@core/stores/appStore.ts"; + +interface AddServerFormData { + hostname: string; + secure: boolean; +} + +interface HTTPServerSectionProps { + onConnect?: () => void; +} + +export const HTTPServerSection = ({ onConnect }: HTTPServerSectionProps) => { + const [connectionInProgress, setConnectionInProgress] = useState(false); + const [connectingToServer, setConnectingToServer] = useState( + null, + ); + const [addServerOpen, setAddServerOpen] = useState(false); + const [connectionError, setConnectionError] = useState(null); + const isURLHTTPS = location.protocol === "https:"; + + const { addDevice } = useDeviceStore(); + const messageStore = useMessageStore(); + const { + setSelectedDevice, + addSavedServer, + removeSavedServer, + clearSavedServers, + getSavedServers, + } = useAppStore(); + + const savedServers = getSavedServers(); + + const { register, handleSubmit, reset, setValue, watch } = useForm< + AddServerFormData + >({ + defaultValues: { + hostname: ["client.meshtastic.org", "localhost"].includes( + globalThis.location.hostname, + ) + ? "meshtastic.local" + : globalThis.location.host, + secure: isURLHTTPS, + }, + }); + + const secureValue = watch("secure"); + + const connectToServer = async (server: SavedServer) => { + setConnectingToServer(server.url); + setConnectionError(null); + + try { + const id = randId(); + const transport = await TransportHTTP.create( + server.host, + server.protocol === "https", + ); + const device = addDevice(id); + const connection = new MeshDevice(transport, id); + connection.configure(); + setSelectedDevice(id); + device.addConnection(connection); + subscribeAll(device, connection, messageStore); + + addSavedServer(server.host, server.protocol); + + onConnect?.(); + } catch (error) { + console.error("Connection error:", error); + setConnectionError(`Failed to connect to ${server.host}`); + } finally { + setConnectingToServer(null); + } + }; + + const handleAddServer = handleSubmit(async (data) => { + setConnectionInProgress(true); + setConnectionError(null); + + try { + const protocol = data.secure ? "https" : "http"; + const id = randId(); + const transport = await TransportHTTP.create(data.hostname, data.secure); + const device = addDevice(id); + const connection = new MeshDevice(transport, id); + connection.configure(); + setSelectedDevice(id); + device.addConnection(connection); + subscribeAll(device, connection, messageStore); + + addSavedServer(data.hostname, protocol); + + setAddServerOpen(false); + reset(); + onConnect?.(); + } catch (error) { + console.error("Connection error:", error); + setConnectionError(`Failed to connect to ${data.hostname}`); + } finally { + setConnectionInProgress(false); + } + }); + + const getSecurityIcon = (protocol: "http" | "https") => { + return protocol === "https" + ? + : ; + }; + + return ( + <> +
+ {/* Header */} +
+
+ +

+ HTTP Servers +

+
+ {savedServers.length > 0 && ( + + )} +
+ + {/* Server List */} +
+ {savedServers.length === 0 + ? ( +
+ +

No HTTP servers added yet

+
+ ) + : ( + savedServers.slice(0, 5).map((server) => ( +
+ {/* Server Info */} +
+
+ + {server.host} + + {getSecurityIcon(server.protocol)} +
+
+ + {/* Actions */} +
+ + + +
+
+ )) + )} + + {/* Add Server Button */} + + + {/* Connection Error */} + {connectionError && ( +
+
+ +

+ {connectionError} +

+
+
+ )} +
+
+ + {/* Add Server Dialog */} + + + + + + Add HTTP Server + + + +
+
+ + +
+ +
+ setValue("secure", checked)} + disabled={isURLHTTPS} + {...register("secure")} + /> + +
+ +
+ + +
+
+
+
+ + ); +}; diff --git a/packages/web/src/components/Dashboard/SerialSection.tsx b/packages/web/src/components/Dashboard/SerialSection.tsx new file mode 100644 index 00000000..ffb13185 --- /dev/null +++ b/packages/web/src/components/Dashboard/SerialSection.tsx @@ -0,0 +1,235 @@ +import { useCallback, useEffect, useState } from "react"; +import { Button } from "@components/UI/Button.tsx"; +import { useAppStore } from "@core/stores/appStore.ts"; +import { useDeviceStore } from "@core/stores/deviceStore.ts"; +import { subscribeAll } from "@core/subscriptions.ts"; +import { randId } from "@core/utils/randId.ts"; +import { MeshDevice } from "@meshtastic/core"; +import { TransportWebSerial } from "@meshtastic/transport-web-serial"; +import { AlertTriangle, Circle, Clock, Plus, Trash2, Usb } from "lucide-react"; +import { useMessageStore } from "@core/stores/messageStore/index.ts"; + +interface SerialSectionProps { + onConnect?: () => void; +} + +export const SerialSection = ({ onConnect }: SerialSectionProps) => { + const [connectionInProgress, setConnectionInProgress] = useState(false); + const [connectingToPort, setConnectingToPort] = useState( + null, + ); + const [serialPorts, setSerialPorts] = useState([]); + const [connectionError, setConnectionError] = useState(null); + + const { addDevice } = useDeviceStore(); + const messageStore = useMessageStore(); + const { setSelectedDevice } = useAppStore(); + + const updateSerialPortList = useCallback(async () => { + try { + setSerialPorts((await navigator?.serial?.getPorts()) ?? []); + } catch (error) { + console.error("Error getting serial ports:", error); + } + }, []); + + useEffect(() => { + const handleConnect = () => updateSerialPortList(); + const handleDisconnect = () => updateSerialPortList(); + + navigator?.serial?.addEventListener("connect", handleConnect); + navigator?.serial?.addEventListener("disconnect", handleDisconnect); + + updateSerialPortList(); + + return () => { + navigator?.serial?.removeEventListener("connect", handleConnect); + navigator?.serial?.removeEventListener("disconnect", handleDisconnect); + }; + }, [updateSerialPortList]); + + const connectToPort = async (port: SerialPort) => { + setConnectingToPort(port); + setConnectionError(null); + + try { + const id = randId(); + const device = addDevice(id); + setSelectedDevice(id); + const transport = await TransportWebSerial.createFromPort(port); + const connection = new MeshDevice(transport, id); + connection.configure(); + device.addConnection(connection); + subscribeAll(device, connection, messageStore); + + onConnect?.(); + } catch (error) { + console.error("Serial connection error:", error); + setConnectionError("Failed to connect to serial device"); + } finally { + setConnectingToPort(null); + } + }; + + const handleAddSerialDevice = async () => { + setConnectionInProgress(true); + setConnectionError(null); + + try { + const port = await navigator.serial.requestPort(); + setSerialPorts([...serialPorts, port]); + } catch (error) { + console.error("Error requesting port:", error); + if (error instanceof Error && !error.message.includes("cancelled")) { + setConnectionError("Failed to add serial device"); + } + } finally { + setConnectionInProgress(false); + } + }; + + const removeSerialPort = (portToRemove: SerialPort) => { + setSerialPorts(serialPorts.filter((port) => port !== portToRemove)); + }; + + const getPortInfo = (port: SerialPort, index: number) => { + const { usbProductId, usbVendorId } = port.getInfo(); + const vendor = usbVendorId + ? `0x${usbVendorId.toString(16).padStart(4, "0")}` + : "Unknown"; + const product = usbProductId + ? `0x${usbProductId.toString(16).padStart(4, "0")}` + : "Unknown"; + return `Serial Port ${index + 1} (${vendor}:${product})`; + }; + + const getStatusIcon = (port: SerialPort) => { + const isConnecting = connectingToPort === port; + const isConnected = port.readable !== null; + + if (isConnecting) { + return ( + + ); + } + if (isConnected) { + return ; + } + return ; + }; + + const getStatusText = (port: SerialPort) => { + if (connectingToPort === port) return "Connecting..."; + return port.readable !== null ? "Connected" : "Available"; + }; + + return ( +
+ {/* Header */} +
+ +

+ Serial Devices +

+
+ + {/* Device List */} +
+ {serialPorts.length === 0 + ? ( +
+ +

No serial devices connected

+
+ ) + : ( + serialPorts.map((port, index) => ( +
+ {/* Status */} +
+ {getStatusIcon(port)} +
+ + {/* Port Info */} +
+
+ + {getPortInfo(port, index)} + + +
+ +
+ {getStatusText(port)} +
+
+ + {/* Actions */} +
+ + + +
+
+ )) + )} + + {/* Add Serial Device Button */} + + + {/* Connection Error */} + {connectionError && ( +
+
+ +

+ {connectionError} +

+
+
+ )} +
+
+ ); +}; diff --git a/packages/web/src/components/Dialog/NewConnectionDialog.tsx b/packages/web/src/components/Dialog/NewConnectionDialog.tsx new file mode 100644 index 00000000..165c2fa4 --- /dev/null +++ b/packages/web/src/components/Dialog/NewConnectionDialog.tsx @@ -0,0 +1,38 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@components/UI/Dialog.tsx"; +import { ConnectionTabs } from "@components/ConnectionTabs/ConnectionTabs.tsx"; +import { useTranslation } from "react-i18next"; + +export interface NewConnectionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const NewConnectionDialog = ({ + open, + onOpenChange, +}: NewConnectionDialogProps) => { + const { t } = useTranslation("dialog"); + + const handleClose = () => { + onOpenChange(false); + }; + + return ( + + + + + {t("newDeviceDialog.title")} + + + + + + + ); +}; diff --git a/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx b/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx index 6ae11735..197b4bad 100644 --- a/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx +++ b/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx @@ -1,11 +1,17 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx"; +import { HTTP as Http } from "./HTTP.tsx"; import { MeshDevice } from "@meshtastic/core"; import { TransportHTTP } from "@meshtastic/transport-http"; import { describe, expect, it, vi } from "vitest"; vi.mock("@core/stores/appStore.ts", () => ({ - useAppStore: vi.fn(() => ({ setSelectedDevice: vi.fn() })), + useAppStore: vi.fn(() => ({ + setSelectedDevice: vi.fn(), + addSavedServer: vi.fn(), + removeSavedServer: vi.fn(), + clearSavedServers: vi.fn(), + getSavedServers: vi.fn(() => []), + })), })); vi.mock("@core/stores/deviceStore.ts", () => ({ @@ -14,10 +20,18 @@ vi.mock("@core/stores/deviceStore.ts", () => ({ })), })); +vi.mock("@core/stores/messageStore/index.ts", () => ({ + useMessageStore: vi.fn(() => ({})), +})); + vi.mock("@core/utils/randId.ts", () => ({ randId: vi.fn(() => "mock-id"), })); +vi.mock("@core/subscriptions.ts", () => ({ + subscribeAll: vi.fn(), +})); + vi.mock("@meshtastic/transport-http", () => ({ TransportHTTP: { create: vi.fn(() => Promise.resolve({})), @@ -32,35 +46,36 @@ vi.mock("@meshtastic/core", () => ({ describe("HTTP Component", () => { it("renders correctly", () => { - render(); - expect(screen.getByText("IP Address/Hostname")).toBeInTheDocument(); - expect(screen.getByRole("textbox")).toBeInTheDocument(); - expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local")) - .toBeInTheDocument(); - expect(screen.getByText("Use HTTPS")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument(); + render(); + expect(screen.getByText("Meshtastic Servers")).toBeInTheDocument(); + expect(screen.getByText("Add New Server")).toBeInTheDocument(); + expect(screen.getByText("No saved servers yet")).toBeInTheDocument(); }); - it("allows input field to be updated", () => { - render(); - const inputField = screen.getByRole("textbox"); - fireEvent.change(inputField, { target: { value: "meshtastic.local" } }); - expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local")) + it("opens dialog when add new server is clicked", () => { + render(); + const addButton = screen.getByText("Add New Server"); + fireEvent.click(addButton); + expect(screen.getByText("Hostname or IP Address")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("meshtastic.local or 192.168.1.100")) .toBeInTheDocument(); }); - it("toggles HTTPS switch and updates prefix", () => { - render(); + it("toggles HTTPS switch in dialog", () => { + render(); + + // Open the dialog first + const addButton = screen.getByText("Add New Server"); + fireEvent.click(addButton); const switchInput = screen.getByRole("switch"); - expect(screen.getByText("http://")).toBeInTheDocument(); + expect(screen.getByText("Use HTTPS (Secure)")).toBeInTheDocument(); fireEvent.click(switchInput); - expect(screen.getByText("https://")).toBeInTheDocument(); + expect(switchInput).toBeChecked(); fireEvent.click(switchInput); expect(switchInput).not.toBeChecked(); - expect(screen.getByText("http://")).toBeInTheDocument(); }); it("enables HTTPS toggle when location protocol is https", () => { @@ -69,17 +84,19 @@ describe("HTTP Component", () => { writable: true, }); - render(); + render(); + + // Open the dialog first + const addButton = screen.getByText("Add New Server"); + fireEvent.click(addButton); const switchInput = screen.getByRole("switch"); expect(switchInput).toBeChecked(); - - expect(screen.getByText("https://")).toBeInTheDocument(); }); it.skip("submits form and triggers connection process", async () => { const closeDialog = vi.fn(); - render(); + render(); const button = screen.getByRole("button", { name: "Connect" }); expect(button).not.toBeDisabled(); diff --git a/packages/web/src/components/PageComponents/Connect/HTTP.tsx b/packages/web/src/components/PageComponents/Connect/HTTP.tsx index d92733af..3d581877 100644 --- a/packages/web/src/components/PageComponents/Connect/HTTP.tsx +++ b/packages/web/src/components/PageComponents/Connect/HTTP.tsx @@ -1,9 +1,14 @@ import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx"; import { Button } from "@components/UI/Button.tsx"; import { Input } from "@components/UI/Input.tsx"; -import { Link } from "@components/UI/Typography/Link.tsx"; import { Label } from "@components/UI/Label.tsx"; import { Switch } from "@components/UI/Switch.tsx"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@components/UI/Dialog.tsx"; import { useAppStore } from "@core/stores/appStore.ts"; import { useDeviceStore } from "@core/stores/deviceStore.ts"; import { subscribeAll } from "@core/subscriptions.ts"; @@ -11,156 +16,391 @@ import { randId } from "@core/utils/randId.ts"; import { MeshDevice } from "@meshtastic/core"; import { TransportHTTP } from "@meshtastic/transport-http"; import { useState } from "react"; -import { useController, useForm } from "react-hook-form"; -import { AlertTriangle } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { + AlertTriangle, + Circle, + Clock, + Lock, + LockOpen, + MessageCircle, + Plus, + Server, + Trash2, + Users, + Wifi, +} from "lucide-react"; import { useMessageStore } from "@core/stores/messageStore/index.ts"; -import { useTranslation } from "react-i18next"; +import type { SavedServer } from "@core/stores/appStore.ts"; -interface FormData { - ip: string; - tls: boolean; +interface AddServerFormData { + hostname: string; + secure: boolean; } -export const HTTP = ( - { closeDialog }: TabElementProps, -) => { - const { t } = useTranslation("dialog"); +export const HTTP = ({ closeDialog }: TabElementProps) => { const [connectionInProgress, setConnectionInProgress] = useState(false); + const [connectingToServer, setConnectingToServer] = useState( + null, + ); + const [addServerOpen, setAddServerOpen] = useState(false); + const [connectionError, setConnectionError] = useState(null); const isURLHTTPS = location.protocol === "https:"; const { addDevice } = useDeviceStore(); const messageStore = useMessageStore(); - const { setSelectedDevice } = useAppStore(); + const { + setSelectedDevice, + addSavedServer, + removeSavedServer, + clearSavedServers, + getSavedServers, + } = useAppStore(); - const { control, handleSubmit, register } = useForm({ + const savedServers = getSavedServers(); + + const { register, handleSubmit, reset, setValue, watch } = useForm< + AddServerFormData + >({ defaultValues: { - ip: ["client.meshtastic.org", "localhost"].includes( + hostname: ["client.meshtastic.org", "localhost"].includes( globalThis.location.hostname, ) ? "meshtastic.local" : globalThis.location.host, - tls: isURLHTTPS ? true : false, + secure: isURLHTTPS, }, }); - const { - field: { value: tlsValue, onChange: setTLS }, - } = useController({ name: "tls", control }); + const secureValue = watch("secure"); - const [connectionError, setConnectionError] = useState< - { host: string; secure: boolean } | null - >(null); + const connectToServer = async (server: SavedServer) => { + setConnectingToServer(server.url); + setConnectionError(null); + + try { + const id = randId(); + const transport = await TransportHTTP.create( + server.host, + server.protocol === "https", + ); + const device = addDevice(id); + const connection = new MeshDevice(transport, id); + connection.configure(); + setSelectedDevice(id); + device.addConnection(connection); + subscribeAll(device, connection, messageStore); - const onSubmit = handleSubmit(async (data) => { + // Update last used time + addSavedServer(server.host, server.protocol); + + closeDialog(); + } catch (error) { + console.error("Connection error:", error); + setConnectionError(`Failed to connect to ${server.host}`); + } finally { + setConnectingToServer(null); + } + }; + + const handleAddServer = handleSubmit(async (data) => { setConnectionInProgress(true); setConnectionError(null); try { + const protocol = data.secure ? "https" : "http"; const id = randId(); - const transport = await TransportHTTP.create(data.ip, data.tls); + const transport = await TransportHTTP.create(data.hostname, data.secure); const device = addDevice(id); const connection = new MeshDevice(transport, id); connection.configure(); setSelectedDevice(id); device.addConnection(connection); subscribeAll(device, connection, messageStore); + + // Save to history + addSavedServer(data.hostname, protocol); + + setAddServerOpen(false); + reset(); closeDialog(); } catch (error) { - if (error instanceof Error) { - console.error("Connection error:", error); - } - // Capture all connection errors regardless of type - setConnectionError({ host: data.ip, secure: data.tls }); + console.error("Connection error:", error); + setConnectionError(`Failed to connect to ${data.hostname}`); + } finally { setConnectionInProgress(false); } }); + const getStatusIcon = (status?: string) => { + switch (status) { + case "online": + return ; + case "offline": + return ; + case "checking": + return ( + + ); + default: + return ; + } + }; + + const getSecurityIcon = (protocol: "http" | "https") => { + return protocol === "https" + ? + : ; + }; + + const getStatusText = (status?: string) => { + switch (status) { + case "online": + return "Online"; + case "offline": + return "Offline"; + case "checking": + return "Checking..."; + default: + return "Unknown"; + } + }; + return ( -
-
+
-
- - -
-
- - + {/* Server List Header */} +
+
+ + + Meshtastic Servers + +
+ {savedServers.length > 0 && ( + + )}
- {connectionError && ( -
-
- -
-

- {t("newDeviceDialog.connectionFailedAlert.title")} + {/* Server List */} +

+ {savedServers.length === 0 + ? ( +
+ +

+ No saved servers yet

-

- {t("newDeviceDialog.connectionFailedAlert.descriptionPrefix")} - {connectionError.secure && - t("newDeviceDialog.connectionFailedAlert.httpsHint")} - {t("newDeviceDialog.connectionFailedAlert.openLinkPrefix")} - - {`${ - connectionError.secure - ? t("newDeviceDialog.https") - : t("newDeviceDialog.http") - }://${connectionError.host}`} - {" "} - {t("newDeviceDialog.connectionFailedAlert.openLinkSuffix")} - {connectionError.secure - ? t( - "newDeviceDialog.connectionFailedAlert.acceptTlsWarningSuffix", - ) - : ""}.{" "} - - {t("newDeviceDialog.connectionFailedAlert.learnMoreLink")} - +

+ Add your first Meshtastic node to get started

+ ) + : ( + savedServers.slice(0, 5).map((server) => ( +
+ {/* Status Indicator */} +
+ {getStatusIcon(server.status)} +
+ + {/* Server Info */} +
+
+ + {server.host} + + {getSecurityIcon(server.protocol)} +
+ +
+ + {(() => { + let statusBgColor = ""; + if (server.status === "online") { + statusBgColor = "bg-green-500"; + } else if (server.status === "offline") { + statusBgColor = "bg-red-500"; + } else { + statusBgColor = "bg-yellow-500"; + } + return ( + + ); + })()} + {getStatusText(server.status)} + + + {/* Device info if available */} + {server.deviceInfo?.model && ( + <> + + + {server.deviceInfo.model} + + + )} + + {server.deviceInfo?.nodeCount && ( + <> + + + + {server.deviceInfo.nodeCount} + + + )} + + {server.deviceInfo?.unreadCount && + server.deviceInfo.unreadCount > 0 && ( + <> + + + + {server.deviceInfo.unreadCount} + + + )} +
+
+ + {/* Actions */} +
+ + + +
+
+ )) + )} +
+ + {/* Add New Server Button */} + + + {/* Connection Error */} + {connectionError && ( +
+
+ +

+ {connectionError} +

)} -
- -
+ + + {/* Add Server Dialog */} + + + + + + Add New Server + + + +
+
+ + +
+ +
+ setValue("secure", checked)} + disabled={isURLHTTPS} + {...register("secure")} + /> + +
+ +
+ + +
+
+
+
+ ); }; diff --git a/packages/web/src/components/PageComponents/Connect/Tabs/BluetoothTab.tsx b/packages/web/src/components/PageComponents/Connect/Tabs/BluetoothTab.tsx new file mode 100644 index 00000000..b996682b --- /dev/null +++ b/packages/web/src/components/PageComponents/Connect/Tabs/BluetoothTab.tsx @@ -0,0 +1,247 @@ +import { useCallback, useEffect, useState } from "react"; +import { Button } from "@components/UI/Button.tsx"; +import { useAppStore } from "@core/stores/appStore.ts"; +import { useDeviceStore } from "@core/stores/deviceStore.ts"; +import { subscribeAll } from "@core/subscriptions.ts"; +import { randId } from "@core/utils/randId.ts"; +import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth"; +import { MeshDevice } from "@meshtastic/core"; +import { + AlertTriangle, + Bluetooth, + Circle, + Clock, + Plus, + Trash2, +} from "lucide-react"; +import { useMessageStore } from "@core/stores/messageStore/index.ts"; +import { useTranslation } from "react-i18next"; + +interface BluetoothTabProps { + closeDialog: () => void; +} + +export const BluetoothTab = ({ closeDialog }: BluetoothTabProps) => { + const { t } = useTranslation("dialog"); + const [connectionInProgress, setConnectionInProgress] = useState(false); + const [connectingToDevice, setConnectingToDevice] = useState( + null, + ); + const [bleDevices, setBleDevices] = useState([]); + const [connectionError, setConnectionError] = useState(null); + + const { addDevice } = useDeviceStore(); + const messageStore = useMessageStore(); + const { setSelectedDevice } = useAppStore(); + + const updateBleDeviceList = useCallback(async (): Promise => { + try { + // Check if Web Bluetooth API and getDevices method are available + if ( + !navigator.bluetooth || + typeof navigator.bluetooth.getDevices !== "function" + ) { + console.warn( + "Web Bluetooth API getDevices() not supported in this browser", + ); + return; + } + setBleDevices(await navigator.bluetooth.getDevices()); + } catch (error) { + console.error("Error getting Bluetooth devices:", error); + } + }, []); + + useEffect(() => { + updateBleDeviceList(); + }, [updateBleDeviceList]); + + const connectToDevice = async (bleDevice: BluetoothDevice) => { + setConnectingToDevice(bleDevice.id); + setConnectionError(null); + + try { + const id = randId(); + const transport = await TransportWebBluetooth.createFromDevice(bleDevice); + const device = addDevice(id); + const connection = new MeshDevice(transport, id); + connection.configure(); + setSelectedDevice(id); + device.addConnection(connection); + subscribeAll(device, connection, messageStore); + + closeDialog(); + } catch (error) { + console.error("Bluetooth connection error:", error); + setConnectionError(`Failed to connect to ${bleDevice.name ?? "device"}`); + } finally { + setConnectingToDevice(null); + } + }; + + const handlePairNewDevice = async () => { + setConnectionInProgress(true); + setConnectionError(null); + + try { + const device = await navigator.bluetooth.requestDevice({ + filters: [{ services: [TransportWebBluetooth.ServiceUuid] }], + }); + + const exists = bleDevices.findIndex((d) => d.id === device.id); + if (exists === -1) { + setBleDevices([...bleDevices, device]); + } + } catch (error) { + console.error("Error pairing device:", error); + if (error instanceof Error && !error.message.includes("cancelled")) { + setConnectionError("Failed to pair new device"); + } + } finally { + setConnectionInProgress(false); + } + }; + + const removeBleDevice = (deviceId: string) => { + setBleDevices(bleDevices.filter((d) => d.id !== deviceId)); + }; + + const getStatusIcon = (device: BluetoothDevice) => { + const isConnecting = connectingToDevice === device.id; + const isConnected = device.gatt?.connected; + + if (isConnecting) { + return ( + + ); + } + if (isConnected) { + return ; + } + return ; + }; + + const getStatusText = (device: BluetoothDevice) => { + if (connectingToDevice === device.id) { + return t("newDeviceDialog.tabs.status.connecting"); + } + return device.gatt?.connected + ? t("newDeviceDialog.tabs.status.connected") + : t("newDeviceDialog.tabs.status.paired"); + }; + + return ( +
+ {/* Header */} +
+ +

+ {t("newDeviceDialog.tabs.bluetooth.title")} +

+
+ + {/* Device List */} +
+ {bleDevices.length === 0 + ? ( +
+ +

+ {t("newDeviceDialog.tabs.bluetooth.noDevices")} +

+

+ {t("newDeviceDialog.tabs.bluetooth.pairFirst")} +

+
+ ) + : ( + bleDevices.map((device) => ( +
+ {/* Status */} +
+ {getStatusIcon(device)} +
+ + {/* Device Info */} +
+
+ + {device.name ?? "Unknown Device"} + + +
+ +
+ {getStatusText(device)} +
+
+ + {/* Actions */} +
+ + + +
+
+ )) + )} + + {/* Pair New Device Button */} + + + {/* Connection Error */} + {connectionError && ( +
+
+ +

+ {connectionError} +

+
+
+ )} +
+
+ ); +}; diff --git a/packages/web/src/components/PageComponents/Connect/Tabs/HTTPTab.tsx b/packages/web/src/components/PageComponents/Connect/Tabs/HTTPTab.tsx new file mode 100644 index 00000000..15246ec6 --- /dev/null +++ b/packages/web/src/components/PageComponents/Connect/Tabs/HTTPTab.tsx @@ -0,0 +1,651 @@ +import { useState } from "react"; +import { Button } from "@components/UI/Button.tsx"; +import { Input } from "@components/UI/Input.tsx"; +import { Label } from "@components/UI/Label.tsx"; +import { Switch } from "@components/UI/Switch.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@components/UI/Dialog.tsx"; +import { useAppStore } from "@core/stores/appStore.ts"; +import { useDeviceStore } from "@core/stores/deviceStore.ts"; +import { subscribeAll } from "@core/subscriptions.ts"; +import { randId } from "@core/utils/randId.ts"; +import { + formatHostnameForConnection, + formatHostnameForTransport, + parseHostname, +} from "@core/utils/hostname.ts"; +import { MeshDevice } from "@meshtastic/core"; +import { TransportHTTP } from "@meshtastic/transport-http"; +import { useForm } from "react-hook-form"; +import { + AlertTriangle, + Clock, + Edit, + Lock, + LockOpen, + Plus, + Server, + Trash2, + Users, + Wifi, +} from "lucide-react"; +import { useMessageStore } from "@core/stores/messageStore/index.ts"; +import type { SavedServer } from "@core/stores/appStore.ts"; +import { useTranslation } from "react-i18next"; + +interface AddServerFormData { + hostname: string; + secure: boolean; +} + +interface EditServerFormData { + hostname: string; + secure: boolean; +} + +interface HTTPTabProps { + closeDialog: () => void; +} + +export const HTTPTab = ({ closeDialog }: HTTPTabProps) => { + const { t } = useTranslation("dialog"); + const [connectionInProgress, setConnectionInProgress] = useState(false); + const [connectingToServer, setConnectingToServer] = useState( + null, + ); + const [addServerOpen, setAddServerOpen] = useState(false); + const [editServerOpen, setEditServerOpen] = useState(false); + const [editingServer, setEditingServer] = useState(null); + const [connectionError, setConnectionError] = useState(null); + const [addServerError, setAddServerError] = useState(null); + const [pendingConnection, setPendingConnection] = useState< + AddServerFormData | null + >(null); + const isURLHTTPS = location.protocol === "https:"; + + const { addDevice } = useDeviceStore(); + const messageStore = useMessageStore(); + const { + setSelectedDevice, + addSavedServer, + removeSavedServer, + clearSavedServers, + getSavedServers, + } = useAppStore(); + + const savedServers = getSavedServers(); + + const { + register: registerAdd, + handleSubmit: handleSubmitAdd, + reset: resetAdd, + setValue: setValueAdd, + watch: watchAdd, + } = useForm({ + defaultValues: { + hostname: ["client.meshtastic.org", "localhost"].includes( + globalThis.location.hostname, + ) + ? "meshtastic.local" + : globalThis.location.host, + secure: true, + }, + }); + + const { + register: registerEdit, + handleSubmit: handleSubmitEdit, + reset: resetEdit, + setValue: setValueEdit, + watch: watchEdit, + } = useForm({ + defaultValues: { + hostname: "", + secure: false, + }, + }); + + const secureValueAdd = watchAdd("secure"); + const secureValueEdit = watchEdit("secure"); + + const retryCertConnection = () => { + if (!pendingConnection) return; + + setAddServerError(null); + const connectionData = pendingConnection; + setPendingConnection(null); + + // Wait a moment for the certificate to be trusted, then retry + setTimeout(async () => { + try { + await attemptConnection(connectionData); + } catch (error) { + console.error("Retry connection failed:", error); + setPendingConnection(connectionData); + setAddServerError( + "Connection still failed. Make sure you accepted the certificate in the device page.", + ); + } + }, 1000); + }; + + const attemptConnection = async (data: AddServerFormData) => { + const parsed = parseHostname(data.hostname); + + if (!parsed.isValid) { + throw new Error(parsed.error ?? "Invalid hostname"); + } + + // Use explicit secure setting if provided, otherwise use parsed protocol + const secure = data.secure || parsed.secure; + const protocol = secure ? "https" : "http"; + const transportHostname = formatHostnameForTransport({ + ...parsed, + secure, + }); + const displayHostname = formatHostnameForConnection({ + ...parsed, + secure, + }); + + // Set up connection timeout (20 seconds for initial connection) + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, 20000); + + try { + const id = randId(); + const transport = await TransportHTTP.create(transportHostname, secure); + const device = addDevice(id); + const connection = new MeshDevice(transport, id); + (connection as MeshDevice & { connType?: string }).connType = "http"; // Add connection type for Dashboard + connection.configure(); + setSelectedDevice(id); + device.addConnection(connection); + subscribeAll(device, connection, messageStore); + + addSavedServer(displayHostname, protocol); + + setAddServerOpen(false); + resetAdd(); + closeDialog(); + } finally { + clearTimeout(timeoutId); + } + }; + + const connectToServer = async (server: SavedServer) => { + setConnectingToServer(server.url); + setConnectionError(null); + + // Set up connection timeout (20 seconds) + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, 20000); + + try { + // Ensure saved server host includes port for transport + const parsed = parseHostname(server.host); + const secure = server.protocol === "https"; + const transportHostname = parsed.isValid + ? formatHostnameForTransport({ ...parsed, secure }) + : server.host; + + const id = randId(); + const transport = await TransportHTTP.create( + transportHostname, + secure, + ); + const device = addDevice(id); + const connection = new MeshDevice(transport, id); + (connection as MeshDevice & { connType?: string }).connType = "http"; // Add connection type for Dashboard + connection.configure(); + setSelectedDevice(id); + device.addConnection(connection); + subscribeAll(device, connection, messageStore); + + addSavedServer(server.host, server.protocol); + + closeDialog(); + } catch (error) { + console.error("Connection error:", error); + const errorMessage = controller.signal.aborted + ? `Connection timed out after 20 seconds connecting to ${server.host}` + : `Failed to connect to ${server.host}`; + setConnectionError(errorMessage); + } finally { + clearTimeout(timeoutId); + setConnectingToServer(null); + } + }; + + const isCertificateError = (errorMessage: string, isSecure: boolean) => { + if (!isSecure) return false; + return errorMessage.includes("certificate") || + errorMessage.includes("SSL") || + errorMessage.includes("TLS") || + errorMessage.includes("CERT_") || + errorMessage.includes("net::ERR_CERT"); + }; + + const isNetworkError = (errorMessage: string) => { + return errorMessage.includes("network") || + errorMessage.includes("fetch") || + errorMessage.includes("CONNECTION_REFUSED"); + }; + + const isTimeoutError = (error: unknown) => { + if (error instanceof Error) { + return error.name === "AbortError" || + error.message.includes("aborted") || + error.message.includes("timeout"); + } + return false; + }; + + const handleConnectionError = (error: unknown, data: AddServerFormData) => { + const errorMessage = error instanceof Error + ? error.message + : `Failed to connect to ${data.hostname}`; + + const parsed = parseHostname(data.hostname); + const secure = data.secure || parsed.secure; + const hostname = parsed.isValid + ? formatHostnameForConnection({ ...parsed, secure }) + : data.hostname; + + if (isTimeoutError(error)) { + setAddServerError( + `Connection timed out after 20 seconds connecting to ${hostname}. Check that the device is powered on and accessible.`, + ); + } else if (isCertificateError(errorMessage, secure)) { + setPendingConnection(data); + setAddServerError( + `SSL certificate error connecting to ${hostname}. Click "Open Device Page" to accept the certificate, then "Retry Connection".`, + ); + } else if (isNetworkError(errorMessage)) { + const port = parsed.port ?? (secure ? 443 : 80); + setAddServerError( + `Network error connecting to ${hostname}. Check that the device is accessible and running on port ${port}.`, + ); + } else { + setAddServerError(`Failed to connect to ${hostname}: ${errorMessage}`); + } + }; + + const handleAddServer = handleSubmitAdd(async (data) => { + setConnectionInProgress(true); + setAddServerError(null); + + try { + await attemptConnection(data); + } catch (error) { + console.error("Connection error:", error); + handleConnectionError(error, data); + } finally { + setConnectionInProgress(false); + } + }); + + const handleEditServer = (server: SavedServer) => { + setEditingServer(server); + resetEdit({ + hostname: server.host, + secure: server.protocol === "https", + }); + setEditServerOpen(true); + }; + + const handleSaveEdit = handleSubmitEdit((data) => { + if (!editingServer) return; + + // Remove old server and add updated one + removeSavedServer(editingServer.url); + addSavedServer(data.hostname, data.secure ? "https" : "http"); + + setEditServerOpen(false); + setEditingServer(null); + resetEdit(); + }); + + const getSecurityIcon = (protocol: "http" | "https") => { + return protocol === "https" + ? + : ; + }; + + return ( + <> +
+ {/* Header */} +
+
+ +

+ {t("newDeviceDialog.tabs.http.title")} +

+
+ {savedServers.length > 0 && ( + + )} +
+ + {/* Server List */} +
+ {savedServers.length === 0 + ? ( +
+ +

+ {t("newDeviceDialog.tabs.http.noServers")} +

+

+ {t("newDeviceDialog.tabs.http.addFirstServer")} +

+
+ ) + : ( + savedServers.map((server) => ( +
+ {/* Server Info */} +
+
+ + {server.host?.replace(/[<>]/g, "")} + + {getSecurityIcon(server.protocol)} +
+ +
+ {server.deviceInfo?.model && ( + <> + + + {server.deviceInfo.model.replace(/[<>]/g, "")} + + + )} + + {server.deviceInfo?.nodeCount && ( + <> + + + + {typeof server.deviceInfo.nodeCount === "number" + ? server.deviceInfo.nodeCount.toString() + : "0"} + + + )} +
+
+ + {/* Actions */} +
+ + + + + +
+
+ )) + )} + + {/* Add Server Button */} + + + {/* Connection Error */} + {connectionError && ( +
+
+ +

+ {connectionError?.replace(/[<>]/g, "")} +

+
+
+ )} +
+
+ + {/* Add Server Dialog */} + + + + + + {t("newDeviceDialog.tabs.http.addServer")} + + + Enter the hostname or IP address of your Meshtastic device to + connect via HTTP/HTTPS. + + + +
+
+ + +
+ +
+ setValueAdd("secure", checked)} + disabled={isURLHTTPS} + {...registerAdd("secure")} + /> + +
+ + {/* Connection Error Display */} + {addServerError && ( +
+
+ +
+

+ {addServerError?.replace(/[<>]/g, "")} +

+ {pendingConnection && ( +
+ + +
+ )} +
+
+
+ )} + +
+ + +
+
+
+
+ + {/* Edit Server Dialog */} + + + + + + {t("newDeviceDialog.tabs.http.editServer")} + + + Update the hostname or connection settings for this Meshtastic + device. + + + +
+
+ + +
+ +
+ setValueEdit("secure", checked)} + {...registerEdit("secure")} + /> + +
+ +
+ + +
+
+
+
+ + ); +}; diff --git a/packages/web/src/components/PageComponents/Connect/Tabs/SerialTab.tsx b/packages/web/src/components/PageComponents/Connect/Tabs/SerialTab.tsx new file mode 100644 index 00000000..f0e9751a --- /dev/null +++ b/packages/web/src/components/PageComponents/Connect/Tabs/SerialTab.tsx @@ -0,0 +1,261 @@ +import { useCallback, useEffect, useState } from "react"; +import { Button } from "@components/UI/Button.tsx"; +import { useAppStore } from "@core/stores/appStore.ts"; +import { useDeviceStore } from "@core/stores/deviceStore.ts"; +import { subscribeAll } from "@core/subscriptions.ts"; +import { randId } from "@core/utils/randId.ts"; +import { MeshDevice } from "@meshtastic/core"; +import { TransportWebSerial } from "@meshtastic/transport-web-serial"; +import { AlertTriangle, Circle, Clock, Plus, Trash2, Usb } from "lucide-react"; +import { useMessageStore } from "@core/stores/messageStore/index.ts"; +import { useTranslation } from "react-i18next"; + +interface SerialTabProps { + closeDialog: () => void; +} + +export const SerialTab = ({ closeDialog }: SerialTabProps) => { + const { t } = useTranslation("dialog"); + const [connectionInProgress, setConnectionInProgress] = useState(false); + const [connectingToPort, setConnectingToPort] = useState( + null, + ); + const [serialPorts, setSerialPorts] = useState([]); + const [connectionError, setConnectionError] = useState(null); + + const { addDevice } = useDeviceStore(); + const messageStore = useMessageStore(); + const { setSelectedDevice } = useAppStore(); + + const updateSerialPortList = useCallback(async () => { + try { + setSerialPorts((await navigator?.serial?.getPorts()) ?? []); + } catch (error) { + console.error("Error getting serial ports:", error); + } + }, []); + + useEffect(() => { + const handleConnect = () => updateSerialPortList(); + const handleDisconnect = () => updateSerialPortList(); + + navigator?.serial?.addEventListener("connect", handleConnect); + navigator?.serial?.addEventListener("disconnect", handleDisconnect); + + updateSerialPortList(); + + return () => { + navigator?.serial?.removeEventListener("connect", handleConnect); + navigator?.serial?.removeEventListener("disconnect", handleDisconnect); + }; + }, [updateSerialPortList]); + + const connectToPort = async (port: SerialPort) => { + setConnectingToPort(port); + setConnectionError(null); + + try { + const id = randId(); + const device = addDevice(id); + setSelectedDevice(id); + const transport = await TransportWebSerial.createFromPort(port); + const connection = new MeshDevice(transport, id); + connection.configure(); + device.addConnection(connection); + subscribeAll(device, connection, messageStore); + + closeDialog(); + } catch (error) { + console.error("Serial connection error:", error); + setConnectionError("Failed to connect to serial device"); + } finally { + setConnectingToPort(null); + } + }; + + const handleAddSerialDevice = async () => { + setConnectionInProgress(true); + setConnectionError(null); + + try { + const port = await navigator.serial.requestPort(); + setSerialPorts([...serialPorts, port]); + } catch (error) { + console.error("Error requesting port:", error); + + // Check for user cancellation - different browsers use different error types/messages + const isDOMCancellation = error instanceof DOMException && + error.name === "NotFoundError"; + const isErrorCancellation = error instanceof Error && ( + error.message.includes("cancelled") || + error.message.includes("canceled") || + error.message.includes("User cancelled") || + error.message.includes("No port selected") + ); + const isUserCancellation = isDOMCancellation || isErrorCancellation; + + if (!isUserCancellation) { + setConnectionError("Failed to add serial device"); + } + // If it's user cancellation, we silently ignore it (no error shown) + } finally { + setConnectionInProgress(false); + } + }; + + const removeSerialPort = (portToRemove: SerialPort) => { + setSerialPorts(serialPorts.filter((port) => port !== portToRemove)); + }; + + const getPortInfo = (port: SerialPort, index: number) => { + const { usbProductId, usbVendorId } = port.getInfo(); + const vendor = usbVendorId + ? `0x${usbVendorId.toString(16).padStart(4, "0")}` + : "Unknown"; + const product = usbProductId + ? `0x${usbProductId.toString(16).padStart(4, "0")}` + : "Unknown"; + return `Serial Port ${index + 1} (${vendor}:${product})`; + }; + + const getStatusIcon = (port: SerialPort) => { + const isConnecting = connectingToPort === port; + const isConnected = port.readable !== null; + + if (isConnecting) { + return ( + + ); + } + if (isConnected) { + return ; + } + return ; + }; + + const getStatusText = (port: SerialPort) => { + if (connectingToPort === port) { + return t("newDeviceDialog.tabs.status.connecting"); + } + return port.readable !== null + ? t("newDeviceDialog.tabs.status.connected") + : t("newDeviceDialog.tabs.status.available"); + }; + + return ( +
+ {/* Header */} +
+ +

+ {t("newDeviceDialog.tabs.serial.title")} +

+
+ + {/* Device List */} +
+ {serialPorts.length === 0 + ? ( +
+ +

+ {t("newDeviceDialog.tabs.serial.noDevices")} +

+

+ {t("newDeviceDialog.tabs.serial.connectFirst")} +

+
+ ) + : ( + serialPorts.map((port, index) => ( +
+ {/* Status */} +
+ {getStatusIcon(port)} +
+ + {/* Port Info */} +
+
+ + {getPortInfo(port, index)} + + +
+ +
+ {getStatusText(port)} +
+
+ + {/* Actions */} +
+ + + +
+
+ )) + )} + + {/* Add Serial Device Button */} + + + {/* Connection Error */} + {connectionError && ( +
+
+ +

+ {connectionError} +

+
+
+ )} +
+
+ ); +}; diff --git a/packages/web/src/components/UI/Tabs.tsx b/packages/web/src/components/UI/Tabs.tsx index 88b048b9..f7d39377 100644 --- a/packages/web/src/components/UI/Tabs.tsx +++ b/packages/web/src/components/UI/Tabs.tsx @@ -26,7 +26,7 @@ const TabsTrigger = React.forwardRef< >(({ className, ...props }, ref) => ( ( // We dispatch a custom event so every similar useLocalStorage hook is notified globalThis.dispatchEvent(new StorageEvent("local-storage", { key })); - }, [key]); + }, [key, initialValue]); useEffect(() => { setStoredValue(readValue()); diff --git a/packages/web/src/core/stores/appStore.ts b/packages/web/src/core/stores/appStore.ts index d7cc67fb..9a2962d9 100644 --- a/packages/web/src/core/stores/appStore.ts +++ b/packages/web/src/core/stores/appStore.ts @@ -8,6 +8,20 @@ export interface RasterSource { tileSize: number; } +export interface SavedServer { + url: string; + protocol: "http" | "https"; + host: string; + lastUsed: number; + status?: "online" | "offline" | "checking"; + deviceInfo?: { + model?: string; + nodeCount?: number; + unreadCount?: number; + firmwareVersion?: string; + }; +} + interface ErrorState { field: string; message: string; @@ -30,6 +44,7 @@ interface AppState { connectDialogOpen: boolean; nodeNumDetails: number; errors: ErrorState[]; + savedServers: SavedServer[]; setRasterSources: (sources: RasterSource[]) => void; addRasterSource: (source: RasterSource) => void; @@ -42,6 +57,16 @@ interface AppState { setConnectDialogOpen: (open: boolean) => void; setNodeNumDetails: (nodeNum: number) => void; + // Server history management + addSavedServer: (host: string, protocol: "http" | "https") => void; + removeSavedServer: (url: string) => void; + clearSavedServers: () => void; + updateServerStatus: ( + url: string, + status: "online" | "offline" | "checking", + ) => void; + getSavedServers: () => SavedServer[]; + // Error management hasErrors: () => boolean; getErrorMessage: (field: string) => string | undefined; @@ -62,6 +87,9 @@ export const useAppStore = create()((set, get) => ({ nodeNumToBeRemoved: 0, nodeNumDetails: 0, errors: [], + savedServers: JSON.parse( + localStorage.getItem("meshtastic-saved-servers") || "[]", + ), setRasterSources: (sources: RasterSource[]) => { set( @@ -162,4 +190,93 @@ export const useAppStore = create()((set, get) => ({ }), ); }, + + // Server history management + addSavedServer: (host: string, protocol: "http" | "https") => { + set( + produce((draft) => { + const url = `${protocol}://${host}`; + const existingIndex = draft.savedServers.findIndex((s) => + s.url === url + ); + + if (existingIndex >= 0) { + // Update last used time if server already exists + draft.savedServers[existingIndex].lastUsed = Date.now(); + } else { + // Add new server + draft.savedServers.push({ + url, + protocol, + host, + lastUsed: Date.now(), + status: "checking", + }); + } + + // Sort by last used (most recent first) and limit to 10 servers + draft.savedServers.sort((a, b) => b.lastUsed - a.lastUsed); + draft.savedServers = draft.savedServers.slice(0, 10); + + // Persist to localStorage + localStorage.setItem( + "meshtastic-saved-servers", + JSON.stringify(draft.savedServers), + ); + }), + ); + }, + + removeSavedServer: (url: string) => { + set( + produce((draft) => { + draft.savedServers = draft.savedServers.filter((s) => s.url !== url); + localStorage.setItem( + "meshtastic-saved-servers", + JSON.stringify(draft.savedServers), + ); + }), + ); + }, + + clearSavedServers: () => { + set( + produce((draft) => { + draft.savedServers = []; + localStorage.removeItem("meshtastic-saved-servers"); + }), + ); + }, + + updateServerStatus: ( + url: string, + status: "online" | "offline" | "checking", + deviceInfo?: { + model?: string; + nodeCount?: number; + unreadCount?: number; + firmwareVersion?: string; + }, + ) => { + set( + produce((draft) => { + const server = draft.savedServers.find((s) => s.url === url); + if (server) { + server.status = status; + if (deviceInfo) { + server.deviceInfo = deviceInfo; + } + localStorage.setItem( + "meshtastic-saved-servers", + JSON.stringify(draft.savedServers), + ); + } + }), + ); + }, + + getSavedServers: () => { + const state = get(); + return [...state.savedServers].sort((a, b) => b.lastUsed - a.lastUsed); + }, })); diff --git a/packages/web/src/core/utils/hostname.ts b/packages/web/src/core/utils/hostname.ts new file mode 100644 index 00000000..af5cf282 --- /dev/null +++ b/packages/web/src/core/utils/hostname.ts @@ -0,0 +1,230 @@ +/** + * Utility functions for parsing and validating hostnames and network addresses + */ + +export interface ParsedHostname { + hostname: string; + port?: number; + secure: boolean; + hasExplicitProtocol: boolean; + isValid: boolean; + error?: string; +} + +const DEFAULT_HTTP_PORT = 80; // Standard HTTP port +const DEFAULT_HTTPS_PORT = 443; // Standard HTTPS port + +/** + * Parses and validates a hostname input, handling various formats: + * - hostname.local + * - 192.168.1.100 + * - hostname:8080 + * - http://hostname + * - https://hostname:8080 + */ +export function parseHostname(input: string): ParsedHostname { + if (!input || typeof input !== "string") { + return { + hostname: "", + secure: false, + hasExplicitProtocol: false, + isValid: false, + error: "Invalid input: hostname is required", + }; + } + + // Trim whitespace and remove dangerous characters + const sanitized = input.trim().replace(/[<>&"']/g, ""); + + if (!sanitized) { + return { + hostname: "", + secure: false, + hasExplicitProtocol: false, + isValid: false, + error: "Invalid input: hostname cannot be empty", + }; + } + + let hostname = sanitized; + let secure = false; + let hasExplicitProtocol = false; + let port: number | undefined; + + // Check for explicit protocol + if (hostname.startsWith("https://")) { + secure = true; + hasExplicitProtocol = true; + hostname = hostname.slice(8); // Remove 'https://' + } else if (hostname.startsWith("http://")) { + secure = false; + hasExplicitProtocol = true; + hostname = hostname.slice(7); // Remove 'http://' + } + + // Remove trailing slash if present + hostname = hostname.replace(/\/$/, ""); + + // Extract port if specified + const portMatch = hostname.match(/^(.+):(\d+)$/); + if (portMatch) { + hostname = portMatch[1]; + const parsedPort = parseInt(portMatch[2], 10); + + if (parsedPort < 1 || parsedPort > 65535) { + return { + hostname: hostname, + secure, + hasExplicitProtocol, + isValid: false, + error: "Invalid port: must be between 1 and 65535", + }; + } + + port = parsedPort; + } + + // Validate hostname format + if (!isValidHostname(hostname)) { + return { + hostname: hostname, + port, + secure, + hasExplicitProtocol, + isValid: false, + error: "Invalid hostname format", + }; + } + + return { + hostname: hostname, + port, + secure, + hasExplicitProtocol, + isValid: true, + }; +} + +/** + * Validates hostname format (domain names and IP addresses) + */ +function isValidHostname(hostname: string): boolean { + if (!hostname || hostname.length === 0) { + return false; + } + + // Check for invalid characters + if (!/^[a-zA-Z0-9.-]+$/.test(hostname)) { + return false; + } + + // Check if it's an IPv4 address + if (isValidIPv4(hostname)) { + return true; + } + + // Check if it's a valid domain name + if (isValidDomain(hostname)) { + return true; + } + + return false; +} + +/** + * Validates IPv4 address format + */ +function isValidIPv4(ip: string): boolean { + const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + const match = ip.match(ipv4Regex); + + if (!match) { + return false; + } + + // Check that each octet is between 0-255 + for (let i = 1; i <= 4; i++) { + const octet = parseInt(match[i], 10); + if (octet < 0 || octet > 255) { + return false; + } + } + + return true; +} + +/** + * Validates domain name format + */ +function isValidDomain(domain: string): boolean { + // Basic domain validation + if (domain.length > 253) { + return false; + } + + // Cannot start or end with hyphen or dot + if ( + domain.startsWith("-") || domain.endsWith("-") || + domain.startsWith(".") || domain.endsWith(".") + ) { + return false; + } + + // Split into labels and validate each + const labels = domain.split("."); + for (const label of labels) { + if (label.length === 0 || label.length > 63) { + return false; + } + + // Label cannot start or end with hyphen + if (label.startsWith("-") || label.endsWith("-")) { + return false; + } + + // Label must contain only alphanumeric and hyphens + if (!/^[a-zA-Z0-9-]+$/.test(label)) { + return false; + } + } + + return true; +} + +/** + * Formats hostname with appropriate default port for connection + */ +export function formatHostnameForConnection(parsed: ParsedHostname): string { + if (!parsed.isValid) { + throw new Error(parsed.error || "Invalid hostname"); + } + + const port = parsed.port || + (parsed.secure ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT); + + // Only include port if it was explicitly provided or if it's not the default + const shouldIncludePort = parsed.port !== undefined; + + return shouldIncludePort ? `${parsed.hostname}:${port}` : parsed.hostname; +} + +/** + * Formats hostname for TransportHTTP.create() - always includes port + */ +export function formatHostnameForTransport(parsed: ParsedHostname): string { + if (!parsed.isValid) { + throw new Error(parsed.error || "Invalid hostname"); + } + + const port = parsed.port || + (parsed.secure ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT); + + return `${parsed.hostname}:${port}`; +} + +/** + * Gets the appropriate protocol string based on parsed hostname + */ +export function getProtocol(parsed: ParsedHostname): "http" | "https" { + return parsed.secure ? "https" : "http"; +} diff --git a/packages/web/src/pages/Dashboard/index.tsx b/packages/web/src/pages/Dashboard/index.tsx index 6afc4546..03cfc5d7 100644 --- a/packages/web/src/pages/Dashboard/index.tsx +++ b/packages/web/src/pages/Dashboard/index.tsx @@ -4,20 +4,30 @@ import { useDeviceStore } from "@core/stores/deviceStore.ts"; import { Button } from "@components/UI/Button.tsx"; import { Separator } from "@components/UI/Seperator.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx"; -import { ListPlusIcon, PlusIcon, UsersIcon } from "lucide-react"; -import { useMemo } from "react"; +import { NewConnectionDialog } from "@components/Dialog/NewConnectionDialog.tsx"; +import { ConnectionTabs } from "@components/ConnectionTabs/ConnectionTabs.tsx"; +import { PlusIcon, UsersIcon } from "lucide-react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import LanguageSwitcher from "@components/LanguageSwitcher.tsx"; export const Dashboard = () => { const { t } = useTranslation("dashboard"); - const { setConnectDialogOpen, setSelectedDevice } = useAppStore(); + const { setSelectedDevice } = useAppStore(); const { getDevices } = useDeviceStore(); + const [connectionDialogOpen, setConnectionDialogOpen] = useState(false); const devices = useMemo(() => getDevices(), [getDevices]); + // Show connection dialog only if user explicitly opens it + // When no devices exist, we show inline connection tabs instead return ( <> + +
@@ -33,9 +43,9 @@ export const Dashboard = () => { -
- {devices.length - ? ( + {devices.length > 0 + ? ( +
    {devices.map((device) => { return ( @@ -71,32 +81,31 @@ export const Dashboard = () => { ); })}
- ) - : ( -
- +
+ +
+
+ ) + : ( +
+
{t("dashboard.noDevicesTitle")} {t("dashboard.noDevicesDescription")} -
- )} -
+ +
+ )}
);