Browse Source

Refactor connection tabs with persistent HTTP

servers and i18n support

  - Move connection tabs from Dialog to PageComponents/Connect/Tabs for better
  organization
  - Add persistent HTTP server storage with status tracking in appStore
  - Implement hostname parsing and validation utilities
  - Default new HTTP connections to HTTPS for better security
  - Add comprehensive i18n translation support for all connection tab strings
  - Remove package-lock.json in favor of deno.lock dependency management
  - Update connection tab UI with improved server management (add/edit/remove)
  - Remove preliminary status checking logic (to be reimplemented with toradio API)
  - Maintain fixed window constraints for large monitor compatibility
pull/645/head
tpaulsoncdw 1 year ago
parent
commit
9ae1256f8d
  1. 3
      .gitignore
  2. 14495
      package-lock.json
  3. 17
      src/components/ConnectionTabs/ConnectionTabs.tsx
  4. 12
      src/components/PageComponents/Connect/HTTP.test.tsx
  5. 145
      src/components/PageComponents/Connect/HTTP.tsx
  6. 2
      src/components/PageComponents/Connect/Serial.tsx
  7. 26
      src/components/PageComponents/Connect/Tabs/BluetoothTab.tsx
  8. 247
      src/components/PageComponents/Connect/Tabs/HTTPTab.tsx
  9. 38
      src/components/PageComponents/Connect/Tabs/SerialTab.tsx
  10. 9
      src/core/stores/appStore.ts
  11. 230
      src/core/utils/hostname.ts
  12. 40
      src/i18n/locales/en/dialog.json

3
.gitignore

@ -4,4 +4,5 @@ stats.html
.vercel
.vite
dev-dist
__screenshots__*
__screenshots__*
package-lock.json

14495
package-lock.json

File diff suppressed because it is too large

17
src/components/ConnectionTabs/ConnectionTabs.tsx

@ -4,9 +4,9 @@ import {
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { HTTPTab } from "@components/Dialog/Tabs/HTTPTab.tsx";
import { BluetoothTab } from "@components/Dialog/Tabs/BluetoothTab.tsx";
import { SerialTab } from "@components/Dialog/Tabs/SerialTab.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 {
@ -24,18 +24,21 @@ export const ConnectionTabs = (
};
return (
<Tabs defaultValue="http" className={`w-full ${className || ""}`}>
<Tabs
defaultValue="http"
className={`w-full max-w-2xl mx-auto ${className || ""}`}
>
<TabsList className="grid w-full grid-cols-3 mb-4">
<TabsTrigger value="http" className="flex items-center gap-2">
<Server className="h-4 w-4" />
<Server className="h-5 w-5" />
HTTP
</TabsTrigger>
<TabsTrigger value="bluetooth" className="flex items-center gap-2">
<Bluetooth className="h-4 w-4" />
<Bluetooth className="h-5 w-5" />
Bluetooth
</TabsTrigger>
<TabsTrigger value="serial" className="flex items-center gap-2">
<Usb className="h-4 w-4" />
<Usb className="h-5 w-5" />
Serial
</TabsTrigger>
</TabsList>

12
src/components/PageComponents/Connect/HTTP.test.tsx

@ -1,5 +1,5 @@
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";
@ -32,7 +32,7 @@ vi.mock("@meshtastic/core", () => ({
describe("HTTP Component", () => {
it("renders correctly", () => {
render(<HTTP closeDialog={vi.fn()} />);
render(<Http closeDialog={vi.fn()} />);
expect(screen.getByText("IP Address/Hostname")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument();
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"))
@ -42,7 +42,7 @@ describe("HTTP Component", () => {
});
it("allows input field to be updated", () => {
render(<HTTP closeDialog={vi.fn()} />);
render(<Http closeDialog={vi.fn()} />);
const inputField = screen.getByRole("textbox");
fireEvent.change(inputField, { target: { value: "meshtastic.local" } });
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"))
@ -50,7 +50,7 @@ describe("HTTP Component", () => {
});
it("toggles HTTPS switch and updates prefix", () => {
render(<HTTP closeDialog={vi.fn()} />);
render(<Http closeDialog={vi.fn()} />);
const switchInput = screen.getByRole("switch");
expect(screen.getByText("http://")).toBeInTheDocument();
@ -69,7 +69,7 @@ describe("HTTP Component", () => {
writable: true,
});
render(<HTTP closeDialog={vi.fn()} />);
render(<Http closeDialog={vi.fn()} />);
const switchInput = screen.getByRole("switch");
expect(switchInput).toBeChecked();
@ -79,7 +79,7 @@ describe("HTTP Component", () => {
it.skip("submits form and triggers connection process", async () => {
const closeDialog = vi.fn();
render(<HTTP closeDialog={closeDialog} />);
render(<Http closeDialog={closeDialog} />);
const button = screen.getByRole("button", { name: "Connect" });
expect(button).not.toBeDisabled();

145
src/components/PageComponents/Connect/HTTP.tsx

@ -15,7 +15,7 @@ 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 { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import {
AlertTriangle,
@ -75,70 +75,79 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
const secureValue = watch("secure");
// Auto-check server status on component mount
useEffect(() => {
savedServers.forEach((server) => {
if (server.status !== "checking") {
checkServerStatus(server);
}
});
}, []);
const checkServerStatus = useCallback(
async (server: SavedServer) => {
updateServerStatus(server.url, "checking");
const checkServerStatus = async (server: SavedServer) => {
updateServerStatus(server.url, "checking");
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
// Try to get actual device info from fromradio endpoint
const response = await fetch(`${server.url}/api/v1/fromradio?all=false`, {
method: "GET",
mode: "cors",
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
// Try to get basic device info if available
try {
// First check if we can get a simple status response
const statusResponse = await fetch(`${server.url}/api/v1/fromradio`, {
// Try to get actual device info from fromradio endpoint
const response = await fetch(
`${server.url}/api/v1/fromradio?all=false`,
{
method: "GET",
mode: "cors",
signal: AbortSignal.timeout(3000),
});
if (statusResponse.ok) {
// For now, just mark as online - we could parse protobuf later for device details
updateServerStatus(server.url, "online");
// TODO: Parse protobuf response to get device info
// This would require proper protobuf decoding which is complex
// For now, we'll show it as online and get device info after connection
} else {
updateServerStatus(server.url, "offline");
signal: controller.signal,
},
);
clearTimeout(timeoutId);
if (response.ok) {
// Try to get basic device info if available
try {
// First check if we can get a simple status response
const statusResponse = await fetch(
`${server.url}/api/v1/fromradio`,
{
method: "GET",
mode: "cors",
signal: AbortSignal.timeout(3000),
},
);
if (statusResponse.ok) {
// For now, just mark as online - we could parse protobuf later for device details
updateServerStatus(server.url, "online");
// TODO: Parse protobuf response to get device info
// This would require proper protobuf decoding which is complex
// For now, we'll show it as online and get device info after connection
} else {
updateServerStatus(server.url, "offline");
}
} catch {
// If fromradio fails, try the simpler status endpoint
const fallbackResponse = await fetch(`${server.url}/`, {
method: "GET",
mode: "cors",
signal: AbortSignal.timeout(3000),
});
updateServerStatus(
server.url,
fallbackResponse.ok ? "online" : "offline",
);
}
} catch {
// If fromradio fails, try the simpler status endpoint
const fallbackResponse = await fetch(`${server.url}/`, {
method: "GET",
mode: "cors",
signal: AbortSignal.timeout(3000),
});
updateServerStatus(
server.url,
fallbackResponse.ok ? "online" : "offline",
);
} else {
updateServerStatus(server.url, "offline");
}
} else {
} catch {
updateServerStatus(server.url, "offline");
}
} catch {
updateServerStatus(server.url, "offline");
},
[updateServerStatus],
);
// Auto-check server status on component mount
useEffect(() => {
for (const server of savedServers) {
if (server.status !== "checking") {
checkServerStatus(server);
}
}
};
}, [savedServers, checkServerStatus]);
const connectToServer = async (server: SavedServer) => {
setConnectingToServer(server.url);
@ -298,15 +307,21 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
<div className="flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400 mt-1">
<span className="flex items-center gap-1">
<span
className={`w-2 h-2 rounded-full ${
server.status === "online"
? "bg-green-500"
: server.status === "offline"
? "bg-red-500"
: "bg-yellow-500"
}`}
/>
{(() => {
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 (
<span
className={`w-2 h-2 rounded-full ${statusBgColor}`}
/>
);
})()}
{getStatusText(server.status)}
</span>

2
src/components/PageComponents/Connect/Serial.tsx

@ -60,7 +60,7 @@ export const Serial = (
const product = usbProductId ?? t("unknown.shortName");
return (
<Button
key={`${vendor}-${product}-${index}`}
key={`${vendor}-${product}-${port.getInfo().usbVendorId}-${port.getInfo().usbProductId}`}
disabled={port.readable !== null}
variant="default"
onClick={async () => {

26
src/components/Dialog/Tabs/BluetoothTab.tsx → src/components/PageComponents/Connect/Tabs/BluetoothTab.tsx

@ -15,12 +15,14 @@ import {
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<string | null>(
null,
@ -120,8 +122,12 @@ export const BluetoothTab = ({ closeDialog }: BluetoothTabProps) => {
};
const getStatusText = (device: BluetoothDevice) => {
if (connectingToDevice === device.id) return "Connecting...";
return device.gatt?.connected ? "Connected" : "Paired";
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 (
@ -130,7 +136,7 @@ export const BluetoothTab = ({ closeDialog }: BluetoothTabProps) => {
<div className="flex items-center gap-2">
<Bluetooth className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-slate-900 dark:text-slate-100">
Bluetooth Devices
{t("newDeviceDialog.tabs.bluetooth.title")}
</h3>
</div>
@ -140,9 +146,11 @@ export const BluetoothTab = ({ closeDialog }: BluetoothTabProps) => {
? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
<Bluetooth className="h-12 w-12 mx-auto mb-3 text-slate-300" />
<p className="text-sm mb-2">No Bluetooth devices paired</p>
<p className="text-sm mb-2">
{t("newDeviceDialog.tabs.bluetooth.noDevices")}
</p>
<p className="text-xs text-slate-400">
Pair your first Meshtastic device
{t("newDeviceDialog.tabs.bluetooth.pairFirst")}
</p>
</div>
)
@ -182,7 +190,9 @@ export const BluetoothTab = ({ closeDialog }: BluetoothTabProps) => {
{connectingToDevice === device.id
? <Clock className="h-3 w-3 mr-1 animate-spin" />
: <Bluetooth className="h-3 w-3 mr-1" />}
{device.gatt?.connected ? "Connected" : "Connect"}
{device.gatt?.connected
? t("newDeviceDialog.tabs.status.connected")
: t("newDeviceDialog.tabs.actions.connect")}
</Button>
<Button
@ -209,13 +219,13 @@ export const BluetoothTab = ({ closeDialog }: BluetoothTabProps) => {
? (
<>
<Clock className="h-4 w-4 mr-2 animate-spin" />
Pairing...
{t("newDeviceDialog.tabs.bluetooth.pairing")}
</>
)
: (
<>
<Plus className="h-4 w-4 mr-2" />
Pair New Device
{t("newDeviceDialog.tabs.bluetooth.pairDevice")}
</>
)}
</Button>

247
src/components/Dialog/Tabs/HTTPTab.tsx → src/components/PageComponents/Connect/Tabs/HTTPTab.tsx

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
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";
@ -14,12 +14,16 @@ 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,
Circle,
Clock,
Edit,
Lock,
@ -32,6 +36,7 @@ import {
} 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;
@ -48,6 +53,7 @@ interface HTTPTabProps {
}
export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
const { t } = useTranslation("dialog");
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [connectingToServer, setConnectingToServer] = useState<string | null>(
null,
@ -69,7 +75,6 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
addSavedServer,
removeSavedServer,
clearSavedServers,
updateServerStatus,
getSavedServers,
} = useAppStore();
@ -88,7 +93,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
)
? "meshtastic.local"
: globalThis.location.host,
secure: isURLHTTPS,
secure: true,
},
});
@ -108,15 +113,6 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
const secureValueAdd = watchAdd("secure");
const secureValueEdit = watchEdit("secure");
// Auto-check server status on component mount
useEffect(() => {
for (const server of savedServers) {
if (server.status !== "checking") {
checkServerStatus(server);
}
}
}, [savedServers]);
const retryCertConnection = () => {
if (!pendingConnection) return;
@ -139,62 +135,48 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
};
const attemptConnection = async (data: AddServerFormData) => {
const protocol = data.secure ? "https" : "http";
const parsed = parseHostname(data.hostname);
// Add default ports if not specified
let hostname = data.hostname;
if (!hostname.includes(":")) {
const defaultPort = data.secure ? "443" : "4403";
hostname = `${hostname}:${defaultPort}`;
if (!parsed.isValid) {
throw new Error(parsed.error ?? "Invalid hostname");
}
const id = randId();
const transport = await TransportHTTP.create(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(hostname, protocol);
setAddServerOpen(false);
resetAdd();
closeDialog();
};
// 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,
});
const checkServerStatus = async (server: SavedServer) => {
updateServerStatus(server.url, "checking");
// Set up connection timeout (20 seconds for initial connection)
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, 20000);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
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);
const response = await fetch(`${server.url}/api/v1/fromradio?all=false`, {
method: "GET",
mode: "cors",
signal: controller.signal,
});
addSavedServer(displayHostname, protocol);
setAddServerOpen(false);
resetAdd();
closeDialog();
} finally {
clearTimeout(timeoutId);
if (response.ok) {
updateServerStatus(server.url, "online");
} else {
const fallbackResponse = await fetch(`${server.url}/`, {
method: "GET",
mode: "cors",
signal: AbortSignal.timeout(3000),
});
updateServerStatus(
server.url,
fallbackResponse.ok ? "online" : "offline",
);
}
} catch (error) {
console.error(`Failed to check server status for ${server.url}:`, error);
updateServerStatus(server.url, "offline");
}
};
@ -202,38 +184,48 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
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(
server.host,
server.protocol === "https",
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);
updateServerStatus(server.url, "online");
closeDialog();
} catch (error) {
console.error("Connection error:", error);
setConnectionError(`Failed to connect to ${server.host}`);
updateServerStatus(server.url, "offline");
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 formatHostname = (hostname: string, secure: boolean) => {
if (hostname.includes(":")) return hostname;
const defaultPort = secure ? "443" : "4403";
return `${hostname}:${defaultPort}`;
};
const isCertificateError = (errorMessage: string, isSecure: boolean) => {
if (!isSecure) return false;
return errorMessage.includes("certificate") ||
@ -249,22 +241,37 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
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 hostname = formatHostname(data.hostname, data.secure);
if (isCertificateError(errorMessage, data.secure)) {
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 defaultPort = data.secure ? "443" : "4403";
const port = hostname.includes(":")
? hostname.split(":")[1]
: defaultPort;
const port = parsed.port ?? (secure ? 443 : 80);
setAddServerError(
`Network error connecting to ${hostname}. Check that the device is accessible and running on port ${port}.`,
);
@ -308,40 +315,12 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
resetEdit();
});
const getStatusIcon = (status?: string) => {
switch (status) {
case "online":
return <Circle className="h-3 w-3 fill-green-500 text-green-500" />;
case "offline":
return <Circle className="h-3 w-3 fill-red-500 text-red-500" />;
case "checking":
return (
<Circle className="h-3 w-3 fill-yellow-500 text-yellow-500 animate-spin" />
);
default:
return <Circle className="h-3 w-3 fill-slate-300 text-slate-300" />;
}
};
const getSecurityIcon = (protocol: "http" | "https") => {
return protocol === "https"
? <Lock className="h-4 w-4 text-green-600" />
: <LockOpen className="h-4 w-4 text-yellow-600" />;
};
const getStatusText = (status?: string) => {
switch (status) {
case "online":
return "Online";
case "offline":
return "Offline";
case "checking":
return "Checking...";
default:
return "Unknown";
}
};
return (
<>
<div className="space-y-4 p-4">
@ -350,7 +329,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
<div className="flex items-center gap-2">
<Server className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-slate-900 dark:text-slate-100">
HTTP Servers
{t("newDeviceDialog.tabs.http.title")}
</h3>
</div>
{savedServers.length > 0 && (
@ -360,7 +339,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
onClick={clearSavedServers}
className="text-red-600 hover:text-red-700"
>
Clear All
{t("newDeviceDialog.tabs.http.clearAll")}
</Button>
)}
</div>
@ -371,9 +350,11 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
<Server className="h-12 w-12 mx-auto mb-3 text-slate-300" />
<p className="text-sm mb-2">No HTTP servers added yet</p>
<p className="text-sm mb-2">
{t("newDeviceDialog.tabs.http.noServers")}
</p>
<p className="text-xs text-slate-400">
Add your first Meshtastic server to get started
{t("newDeviceDialog.tabs.http.addFirstServer")}
</p>
</div>
)
@ -383,11 +364,6 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
key={server.url}
className="flex items-center gap-3 p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
{/* Status */}
<div className="flex-shrink-0">
{getStatusIcon(server.status)}
</div>
{/* Server Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
@ -398,8 +374,6 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
</div>
<div className="flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400 mt-1">
<span>{getStatusText(server.status)}</span>
{server.deviceInfo?.model && (
<>
<span></span>
@ -433,7 +407,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
{connectingToServer === server.url
? <Clock className="h-3 w-3 mr-1 animate-spin" />
: <Wifi className="h-3 w-3 mr-1" />}
Connect
{t("newDeviceDialog.tabs.actions.connect")}
</Button>
<Button
@ -442,7 +416,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
onClick={() => handleEditServer(server)}
className="text-slate-400 hover:text-blue-600 p-1"
>
<Edit className="h-3 w-3" />
<Edit className="h-5 w-5" />
</Button>
<Button
@ -451,7 +425,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
onClick={() => removeSavedServer(server.url)}
className="text-slate-400 hover:text-red-600 p-1"
>
<Trash2 className="h-3 w-3" />
<Trash2 className="h-5 w-5" />
</Button>
</div>
</div>
@ -468,7 +442,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
className="w-full border-dashed hover:border-solid"
>
<Plus className="h-4 w-4 mr-2" />
Add HTTP Server
{t("newDeviceDialog.tabs.http.addNewDevice")}
</Button>
{/* Connection Error */}
@ -491,7 +465,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Add HTTP Server
{t("newDeviceDialog.tabs.http.addServer")}
</DialogTitle>
<DialogDescription>
Enter the hostname or IP address of your Meshtastic device to
@ -541,16 +515,19 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
variant="outline"
size="sm"
onClick={() => {
const protocol = pendingConnection.secure
? "https"
: "http";
let hostname = pendingConnection.hostname;
if (!hostname.includes(":")) {
const defaultPort = pendingConnection.secure
? "443"
: "4403";
hostname = `${hostname}:${defaultPort}`;
}
const parsed = parseHostname(
pendingConnection.hostname,
);
const secure = pendingConnection.secure ||
parsed.secure;
const protocol = secure ? "https" : "http";
const hostname = parsed.isValid
? formatHostnameForConnection({
...parsed,
secure,
})
: pendingConnection.hostname;
// Open the node directly to accept certificate
const nodeUrl = `${protocol}://${hostname}`;
globalThis.open(
@ -585,7 +562,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
onClick={() => setAddServerOpen(false)}
className="flex-1"
>
Cancel
{t("newDeviceDialog.tabs.actions.cancel")}
</Button>
<Button
type="submit"
@ -617,7 +594,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Edit className="h-4 w-4" />
Edit HTTP Server
{t("newDeviceDialog.tabs.http.editServer")}
</DialogTitle>
<DialogDescription>
Update the hostname or connection settings for this Meshtastic
@ -657,13 +634,13 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
onClick={() => setEditServerOpen(false)}
className="flex-1"
>
Cancel
{t("newDeviceDialog.tabs.actions.cancel")}
</Button>
<Button
type="submit"
className="flex-1"
>
Save Changes
{t("newDeviceDialog.tabs.actions.saveChanges")}
</Button>
</div>
</form>

38
src/components/Dialog/Tabs/SerialTab.tsx → src/components/PageComponents/Connect/Tabs/SerialTab.tsx

@ -8,12 +8,14 @@ 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<SerialPort | null>(
null,
@ -27,7 +29,7 @@ export const SerialTab = ({ closeDialog }: SerialTabProps) => {
const updateSerialPortList = useCallback(async () => {
try {
setSerialPorts(await navigator?.serial?.getPorts() || []);
setSerialPorts((await navigator?.serial?.getPorts()) ?? []);
} catch (error) {
console.error("Error getting serial ports:", error);
}
@ -124,16 +126,20 @@ export const SerialTab = ({ closeDialog }: SerialTabProps) => {
return (
<Circle className="h-3 w-3 fill-yellow-500 text-yellow-500 animate-spin" />
);
} else if (isConnected) {
}
if (isConnected) {
return <Circle className="h-3 w-3 fill-green-500 text-green-500" />;
} else {
return <Circle className="h-3 w-3 fill-slate-400 text-slate-400" />;
}
return <Circle className="h-3 w-3 fill-slate-400 text-slate-400" />;
};
const getStatusText = (port: SerialPort) => {
if (connectingToPort === port) return "Connecting...";
return port.readable !== null ? "Connected" : "Available";
if (connectingToPort === port) {
return t("newDeviceDialog.tabs.status.connecting");
}
return port.readable !== null
? t("newDeviceDialog.tabs.status.connected")
: t("newDeviceDialog.tabs.status.available");
};
return (
@ -142,7 +148,7 @@ export const SerialTab = ({ closeDialog }: SerialTabProps) => {
<div className="flex items-center gap-2">
<Usb className="h-5 w-5 text-green-600" />
<h3 className="font-semibold text-slate-900 dark:text-slate-100">
Serial Devices
{t("newDeviceDialog.tabs.serial.title")}
</h3>
</div>
@ -152,16 +158,20 @@ export const SerialTab = ({ closeDialog }: SerialTabProps) => {
? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
<Usb className="h-12 w-12 mx-auto mb-3 text-slate-300" />
<p className="text-sm mb-2">No serial devices connected</p>
<p className="text-sm mb-2">
{t("newDeviceDialog.tabs.serial.noDevices")}
</p>
<p className="text-xs text-slate-400">
Connect your first Meshtastic device via USB
{t("newDeviceDialog.tabs.serial.connectFirst")}
</p>
</div>
)
: (
serialPorts.map((port, index) => (
<div
key={`${port}-${index}`}
key={`serial-${port.getInfo().usbVendorId ?? "unknown"}-${
port.getInfo().usbProductId ?? "unknown"
}`}
className="flex items-center gap-3 p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
{/* Status */}
@ -194,7 +204,9 @@ export const SerialTab = ({ closeDialog }: SerialTabProps) => {
{connectingToPort === port
? <Clock className="h-3 w-3 mr-1 animate-spin" />
: <Usb className="h-3 w-3 mr-1" />}
{port.readable !== null ? "Connected" : "Connect"}
{port.readable !== null
? t("newDeviceDialog.tabs.status.connected")
: t("newDeviceDialog.tabs.actions.connect")}
</Button>
<Button
@ -221,13 +233,13 @@ export const SerialTab = ({ closeDialog }: SerialTabProps) => {
? (
<>
<Clock className="h-4 w-4 mr-2 animate-spin" />
Adding...
{t("newDeviceDialog.tabs.serial.adding")}
</>
)
: (
<>
<Plus className="h-4 w-4 mr-2" />
Add Serial Device
{t("newDeviceDialog.tabs.serial.addDevice")}
</>
)}
</Button>

9
src/core/stores/appStore.ts

@ -251,12 +251,21 @@ export const useAppStore = create<AppState>()((set, get) => ({
updateServerStatus: (
url: string,
status: "online" | "offline" | "checking",
deviceInfo?: {
model?: string;
nodeCount?: number;
unreadCount?: number;
firmwareVersion?: string;
},
) => {
set(
produce<AppState>((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),

230
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";
}

40
src/i18n/locales/en/dialog.json

@ -67,6 +67,46 @@
"requiresFeatures": "This connection type requires <0></0>. Please use a supported browser, like Chrome or Edge.",
"requiresSecureContext": "This application requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
"additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context</0>. 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": {

Loading…
Cancel
Save