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 .vercel
.vite .vite
dev-dist 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, TabsList,
TabsTrigger, TabsTrigger,
} from "@components/UI/Tabs.tsx"; } from "@components/UI/Tabs.tsx";
import { HTTPTab } from "@components/Dialog/Tabs/HTTPTab.tsx"; import { HTTPTab } from "@components/PageComponents/Connect/Tabs/HTTPTab.tsx";
import { BluetoothTab } from "@components/Dialog/Tabs/BluetoothTab.tsx"; import { BluetoothTab } from "@components/PageComponents/Connect/Tabs/BluetoothTab.tsx";
import { SerialTab } from "@components/Dialog/Tabs/SerialTab.tsx"; import { SerialTab } from "@components/PageComponents/Connect/Tabs/SerialTab.tsx";
import { Bluetooth, Server, Usb } from "lucide-react"; import { Bluetooth, Server, Usb } from "lucide-react";
interface ConnectionTabsProps { interface ConnectionTabsProps {
@ -24,18 +24,21 @@ export const ConnectionTabs = (
}; };
return ( 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"> <TabsList className="grid w-full grid-cols-3 mb-4">
<TabsTrigger value="http" className="flex items-center gap-2"> <TabsTrigger value="http" className="flex items-center gap-2">
<Server className="h-4 w-4" /> <Server className="h-5 w-5" />
HTTP HTTP
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="bluetooth" className="flex items-center gap-2"> <TabsTrigger value="bluetooth" className="flex items-center gap-2">
<Bluetooth className="h-4 w-4" /> <Bluetooth className="h-5 w-5" />
Bluetooth Bluetooth
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="serial" className="flex items-center gap-2"> <TabsTrigger value="serial" className="flex items-center gap-2">
<Usb className="h-4 w-4" /> <Usb className="h-5 w-5" />
Serial Serial
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>

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

@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; 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 { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http"; import { TransportHTTP } from "@meshtastic/transport-http";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
@ -32,7 +32,7 @@ vi.mock("@meshtastic/core", () => ({
describe("HTTP Component", () => { describe("HTTP Component", () => {
it("renders correctly", () => { it("renders correctly", () => {
render(<HTTP closeDialog={vi.fn()} />); render(<Http closeDialog={vi.fn()} />);
expect(screen.getByText("IP Address/Hostname")).toBeInTheDocument(); expect(screen.getByText("IP Address/Hostname")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument(); expect(screen.getByRole("textbox")).toBeInTheDocument();
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local")) expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"))
@ -42,7 +42,7 @@ describe("HTTP Component", () => {
}); });
it("allows input field to be updated", () => { it("allows input field to be updated", () => {
render(<HTTP closeDialog={vi.fn()} />); render(<Http closeDialog={vi.fn()} />);
const inputField = screen.getByRole("textbox"); const inputField = screen.getByRole("textbox");
fireEvent.change(inputField, { target: { value: "meshtastic.local" } }); fireEvent.change(inputField, { target: { value: "meshtastic.local" } });
expect(screen.getByPlaceholderText("000.000.000.000 / 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", () => { it("toggles HTTPS switch and updates prefix", () => {
render(<HTTP closeDialog={vi.fn()} />); render(<Http closeDialog={vi.fn()} />);
const switchInput = screen.getByRole("switch"); const switchInput = screen.getByRole("switch");
expect(screen.getByText("http://")).toBeInTheDocument(); expect(screen.getByText("http://")).toBeInTheDocument();
@ -69,7 +69,7 @@ describe("HTTP Component", () => {
writable: true, writable: true,
}); });
render(<HTTP closeDialog={vi.fn()} />); render(<Http closeDialog={vi.fn()} />);
const switchInput = screen.getByRole("switch"); const switchInput = screen.getByRole("switch");
expect(switchInput).toBeChecked(); expect(switchInput).toBeChecked();
@ -79,7 +79,7 @@ describe("HTTP Component", () => {
it.skip("submits form and triggers connection process", async () => { it.skip("submits form and triggers connection process", async () => {
const closeDialog = vi.fn(); const closeDialog = vi.fn();
render(<HTTP closeDialog={closeDialog} />); render(<Http closeDialog={closeDialog} />);
const button = screen.getByRole("button", { name: "Connect" }); const button = screen.getByRole("button", { name: "Connect" });
expect(button).not.toBeDisabled(); 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 { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core"; import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http"; import { TransportHTTP } from "@meshtastic/transport-http";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { import {
AlertTriangle, AlertTriangle,
@ -75,70 +75,79 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
const secureValue = watch("secure"); const secureValue = watch("secure");
// Auto-check server status on component mount const checkServerStatus = useCallback(
useEffect(() => { async (server: SavedServer) => {
savedServers.forEach((server) => { updateServerStatus(server.url, "checking");
if (server.status !== "checking") {
checkServerStatus(server);
}
});
}, []);
const checkServerStatus = async (server: SavedServer) => { try {
updateServerStatus(server.url, "checking"); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
try { // Try to get actual device info from fromradio endpoint
const controller = new AbortController(); const response = await fetch(
const timeoutId = setTimeout(() => controller.abort(), 8000); `${server.url}/api/v1/fromradio?all=false`,
{
// 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`, {
method: "GET", method: "GET",
mode: "cors", mode: "cors",
signal: AbortSignal.timeout(3000), signal: controller.signal,
}); },
);
if (statusResponse.ok) {
// For now, just mark as online - we could parse protobuf later for device details clearTimeout(timeoutId);
updateServerStatus(server.url, "online");
if (response.ok) {
// TODO: Parse protobuf response to get device info // Try to get basic device info if available
// This would require proper protobuf decoding which is complex try {
// For now, we'll show it as online and get device info after connection // First check if we can get a simple status response
} else { const statusResponse = await fetch(
updateServerStatus(server.url, "offline"); `${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 { } else {
// If fromradio fails, try the simpler status endpoint updateServerStatus(server.url, "offline");
const fallbackResponse = await fetch(`${server.url}/`, {
method: "GET",
mode: "cors",
signal: AbortSignal.timeout(3000),
});
updateServerStatus(
server.url,
fallbackResponse.ok ? "online" : "offline",
);
} }
} else { } catch {
updateServerStatus(server.url, "offline"); 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) => { const connectToServer = async (server: SavedServer) => {
setConnectingToServer(server.url); 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"> <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="flex items-center gap-1">
<span {(() => {
className={`w-2 h-2 rounded-full ${ let statusBgColor = "";
server.status === "online" if (server.status === "online") {
? "bg-green-500" statusBgColor = "bg-green-500";
: server.status === "offline" } else if (server.status === "offline") {
? "bg-red-500" statusBgColor = "bg-red-500";
: "bg-yellow-500" } else {
}`} statusBgColor = "bg-yellow-500";
/> }
return (
<span
className={`w-2 h-2 rounded-full ${statusBgColor}`}
/>
);
})()}
{getStatusText(server.status)} {getStatusText(server.status)}
</span> </span>

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

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

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

@ -15,12 +15,14 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore/index.ts"; import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
interface BluetoothTabProps { interface BluetoothTabProps {
closeDialog: () => void; closeDialog: () => void;
} }
export const BluetoothTab = ({ closeDialog }: BluetoothTabProps) => { export const BluetoothTab = ({ closeDialog }: BluetoothTabProps) => {
const { t } = useTranslation("dialog");
const [connectionInProgress, setConnectionInProgress] = useState(false); const [connectionInProgress, setConnectionInProgress] = useState(false);
const [connectingToDevice, setConnectingToDevice] = useState<string | null>( const [connectingToDevice, setConnectingToDevice] = useState<string | null>(
null, null,
@ -120,8 +122,12 @@ export const BluetoothTab = ({ closeDialog }: BluetoothTabProps) => {
}; };
const getStatusText = (device: BluetoothDevice) => { const getStatusText = (device: BluetoothDevice) => {
if (connectingToDevice === device.id) return "Connecting..."; if (connectingToDevice === device.id) {
return device.gatt?.connected ? "Connected" : "Paired"; return t("newDeviceDialog.tabs.status.connecting");
}
return device.gatt?.connected
? t("newDeviceDialog.tabs.status.connected")
: t("newDeviceDialog.tabs.status.paired");
}; };
return ( return (
@ -130,7 +136,7 @@ export const BluetoothTab = ({ closeDialog }: BluetoothTabProps) => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Bluetooth className="h-5 w-5 text-blue-600" /> <Bluetooth className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-slate-900 dark:text-slate-100"> <h3 className="font-semibold text-slate-900 dark:text-slate-100">
Bluetooth Devices {t("newDeviceDialog.tabs.bluetooth.title")}
</h3> </h3>
</div> </div>
@ -140,9 +146,11 @@ export const BluetoothTab = ({ closeDialog }: BluetoothTabProps) => {
? ( ? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400"> <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" /> <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"> <p className="text-xs text-slate-400">
Pair your first Meshtastic device {t("newDeviceDialog.tabs.bluetooth.pairFirst")}
</p> </p>
</div> </div>
) )
@ -182,7 +190,9 @@ export const BluetoothTab = ({ closeDialog }: BluetoothTabProps) => {
{connectingToDevice === device.id {connectingToDevice === device.id
? <Clock className="h-3 w-3 mr-1 animate-spin" /> ? <Clock className="h-3 w-3 mr-1 animate-spin" />
: <Bluetooth className="h-3 w-3 mr-1" />} : <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>
<Button <Button
@ -209,13 +219,13 @@ export const BluetoothTab = ({ closeDialog }: BluetoothTabProps) => {
? ( ? (
<> <>
<Clock className="h-4 w-4 mr-2 animate-spin" /> <Clock className="h-4 w-4 mr-2 animate-spin" />
Pairing... {t("newDeviceDialog.tabs.bluetooth.pairing")}
</> </>
) )
: ( : (
<> <>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Pair New Device {t("newDeviceDialog.tabs.bluetooth.pairDevice")}
</> </>
)} )}
</Button> </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 { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.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 { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts"; import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts"; import { randId } from "@core/utils/randId.ts";
import {
formatHostnameForConnection,
formatHostnameForTransport,
parseHostname,
} from "@core/utils/hostname.ts";
import { MeshDevice } from "@meshtastic/core"; import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http"; import { TransportHTTP } from "@meshtastic/transport-http";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { import {
AlertTriangle, AlertTriangle,
Circle,
Clock, Clock,
Edit, Edit,
Lock, Lock,
@ -32,6 +36,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore/index.ts"; import { useMessageStore } from "@core/stores/messageStore/index.ts";
import type { SavedServer } from "@core/stores/appStore.ts"; import type { SavedServer } from "@core/stores/appStore.ts";
import { useTranslation } from "react-i18next";
interface AddServerFormData { interface AddServerFormData {
hostname: string; hostname: string;
@ -48,6 +53,7 @@ interface HTTPTabProps {
} }
export const HTTPTab = ({ closeDialog }: HTTPTabProps) => { export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
const { t } = useTranslation("dialog");
const [connectionInProgress, setConnectionInProgress] = useState(false); const [connectionInProgress, setConnectionInProgress] = useState(false);
const [connectingToServer, setConnectingToServer] = useState<string | null>( const [connectingToServer, setConnectingToServer] = useState<string | null>(
null, null,
@ -69,7 +75,6 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
addSavedServer, addSavedServer,
removeSavedServer, removeSavedServer,
clearSavedServers, clearSavedServers,
updateServerStatus,
getSavedServers, getSavedServers,
} = useAppStore(); } = useAppStore();
@ -88,7 +93,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
) )
? "meshtastic.local" ? "meshtastic.local"
: globalThis.location.host, : globalThis.location.host,
secure: isURLHTTPS, secure: true,
}, },
}); });
@ -108,15 +113,6 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
const secureValueAdd = watchAdd("secure"); const secureValueAdd = watchAdd("secure");
const secureValueEdit = watchEdit("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 = () => { const retryCertConnection = () => {
if (!pendingConnection) return; if (!pendingConnection) return;
@ -139,62 +135,48 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
}; };
const attemptConnection = async (data: AddServerFormData) => { const attemptConnection = async (data: AddServerFormData) => {
const protocol = data.secure ? "https" : "http"; const parsed = parseHostname(data.hostname);
// Add default ports if not specified if (!parsed.isValid) {
let hostname = data.hostname; throw new Error(parsed.error ?? "Invalid hostname");
if (!hostname.includes(":")) {
const defaultPort = data.secure ? "443" : "4403";
hostname = `${hostname}:${defaultPort}`;
} }
const id = randId(); // Use explicit secure setting if provided, otherwise use parsed protocol
const transport = await TransportHTTP.create(hostname, data.secure); const secure = data.secure || parsed.secure;
const device = addDevice(id); const protocol = secure ? "https" : "http";
const connection = new MeshDevice(transport, id); const transportHostname = formatHostnameForTransport({
connection.configure(); ...parsed,
setSelectedDevice(id); secure,
device.addConnection(connection); });
subscribeAll(device, connection, messageStore); const displayHostname = formatHostnameForConnection({
...parsed,
addSavedServer(hostname, protocol); secure,
});
setAddServerOpen(false);
resetAdd();
closeDialog();
};
const checkServerStatus = async (server: SavedServer) => { // Set up connection timeout (20 seconds for initial connection)
updateServerStatus(server.url, "checking"); const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, 20000);
try { try {
const controller = new AbortController(); const id = randId();
const timeoutId = setTimeout(() => controller.abort(), 8000); 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`, { addSavedServer(displayHostname, protocol);
method: "GET",
mode: "cors",
signal: controller.signal,
});
setAddServerOpen(false);
resetAdd();
closeDialog();
} finally {
clearTimeout(timeoutId); 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); setConnectingToServer(server.url);
setConnectionError(null); setConnectionError(null);
// Set up connection timeout (20 seconds)
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, 20000);
try { 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 id = randId();
const transport = await TransportHTTP.create( const transport = await TransportHTTP.create(
server.host, transportHostname,
server.protocol === "https", secure,
); );
const device = addDevice(id); const device = addDevice(id);
const connection = new MeshDevice(transport, id); const connection = new MeshDevice(transport, id);
(connection as MeshDevice & { connType?: string }).connType = "http"; // Add connection type for Dashboard
connection.configure(); connection.configure();
setSelectedDevice(id); setSelectedDevice(id);
device.addConnection(connection); device.addConnection(connection);
subscribeAll(device, connection, messageStore); subscribeAll(device, connection, messageStore);
addSavedServer(server.host, server.protocol); addSavedServer(server.host, server.protocol);
updateServerStatus(server.url, "online");
closeDialog(); closeDialog();
} catch (error) { } catch (error) {
console.error("Connection error:", error); console.error("Connection error:", error);
setConnectionError(`Failed to connect to ${server.host}`); const errorMessage = controller.signal.aborted
updateServerStatus(server.url, "offline"); ? `Connection timed out after 20 seconds connecting to ${server.host}`
: `Failed to connect to ${server.host}`;
setConnectionError(errorMessage);
} finally { } finally {
clearTimeout(timeoutId);
setConnectingToServer(null); 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) => { const isCertificateError = (errorMessage: string, isSecure: boolean) => {
if (!isSecure) return false; if (!isSecure) return false;
return errorMessage.includes("certificate") || return errorMessage.includes("certificate") ||
@ -249,22 +241,37 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
errorMessage.includes("CONNECTION_REFUSED"); 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 handleConnectionError = (error: unknown, data: AddServerFormData) => {
const errorMessage = error instanceof Error const errorMessage = error instanceof Error
? error.message ? error.message
: `Failed to connect to ${data.hostname}`; : `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); setPendingConnection(data);
setAddServerError( setAddServerError(
`SSL certificate error connecting to ${hostname}. Click "Open Device Page" to accept the certificate, then "Retry Connection".`, `SSL certificate error connecting to ${hostname}. Click "Open Device Page" to accept the certificate, then "Retry Connection".`,
); );
} else if (isNetworkError(errorMessage)) { } else if (isNetworkError(errorMessage)) {
const defaultPort = data.secure ? "443" : "4403"; const port = parsed.port ?? (secure ? 443 : 80);
const port = hostname.includes(":")
? hostname.split(":")[1]
: defaultPort;
setAddServerError( setAddServerError(
`Network error connecting to ${hostname}. Check that the device is accessible and running on port ${port}.`, `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(); 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") => { const getSecurityIcon = (protocol: "http" | "https") => {
return protocol === "https" return protocol === "https"
? <Lock className="h-4 w-4 text-green-600" /> ? <Lock className="h-4 w-4 text-green-600" />
: <LockOpen className="h-4 w-4 text-yellow-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 ( return (
<> <>
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
@ -350,7 +329,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Server className="h-5 w-5 text-blue-600" /> <Server className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-slate-900 dark:text-slate-100"> <h3 className="font-semibold text-slate-900 dark:text-slate-100">
HTTP Servers {t("newDeviceDialog.tabs.http.title")}
</h3> </h3>
</div> </div>
{savedServers.length > 0 && ( {savedServers.length > 0 && (
@ -360,7 +339,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
onClick={clearSavedServers} onClick={clearSavedServers}
className="text-red-600 hover:text-red-700" className="text-red-600 hover:text-red-700"
> >
Clear All {t("newDeviceDialog.tabs.http.clearAll")}
</Button> </Button>
)} )}
</div> </div>
@ -371,9 +350,11 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
? ( ? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400"> <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" /> <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"> <p className="text-xs text-slate-400">
Add your first Meshtastic server to get started {t("newDeviceDialog.tabs.http.addFirstServer")}
</p> </p>
</div> </div>
) )
@ -383,11 +364,6 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
key={server.url} 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" 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 */} {/* Server Info */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -398,8 +374,6 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
</div> </div>
<div className="flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400 mt-1"> <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 && ( {server.deviceInfo?.model && (
<> <>
<span></span> <span></span>
@ -433,7 +407,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
{connectingToServer === server.url {connectingToServer === server.url
? <Clock className="h-3 w-3 mr-1 animate-spin" /> ? <Clock className="h-3 w-3 mr-1 animate-spin" />
: <Wifi className="h-3 w-3 mr-1" />} : <Wifi className="h-3 w-3 mr-1" />}
Connect {t("newDeviceDialog.tabs.actions.connect")}
</Button> </Button>
<Button <Button
@ -442,7 +416,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
onClick={() => handleEditServer(server)} onClick={() => handleEditServer(server)}
className="text-slate-400 hover:text-blue-600 p-1" className="text-slate-400 hover:text-blue-600 p-1"
> >
<Edit className="h-3 w-3" /> <Edit className="h-5 w-5" />
</Button> </Button>
<Button <Button
@ -451,7 +425,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
onClick={() => removeSavedServer(server.url)} onClick={() => removeSavedServer(server.url)}
className="text-slate-400 hover:text-red-600 p-1" className="text-slate-400 hover:text-red-600 p-1"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-5 w-5" />
</Button> </Button>
</div> </div>
</div> </div>
@ -468,7 +442,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
className="w-full border-dashed hover:border-solid" className="w-full border-dashed hover:border-solid"
> >
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Add HTTP Server {t("newDeviceDialog.tabs.http.addNewDevice")}
</Button> </Button>
{/* Connection Error */} {/* Connection Error */}
@ -491,7 +465,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add HTTP Server {t("newDeviceDialog.tabs.http.addServer")}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Enter the hostname or IP address of your Meshtastic device to Enter the hostname or IP address of your Meshtastic device to
@ -541,16 +515,19 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
const protocol = pendingConnection.secure const parsed = parseHostname(
? "https" pendingConnection.hostname,
: "http"; );
let hostname = pendingConnection.hostname; const secure = pendingConnection.secure ||
if (!hostname.includes(":")) { parsed.secure;
const defaultPort = pendingConnection.secure const protocol = secure ? "https" : "http";
? "443" const hostname = parsed.isValid
: "4403"; ? formatHostnameForConnection({
hostname = `${hostname}:${defaultPort}`; ...parsed,
} secure,
})
: pendingConnection.hostname;
// Open the node directly to accept certificate // Open the node directly to accept certificate
const nodeUrl = `${protocol}://${hostname}`; const nodeUrl = `${protocol}://${hostname}`;
globalThis.open( globalThis.open(
@ -585,7 +562,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
onClick={() => setAddServerOpen(false)} onClick={() => setAddServerOpen(false)}
className="flex-1" className="flex-1"
> >
Cancel {t("newDeviceDialog.tabs.actions.cancel")}
</Button> </Button>
<Button <Button
type="submit" type="submit"
@ -617,7 +594,7 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
Edit HTTP Server {t("newDeviceDialog.tabs.http.editServer")}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Update the hostname or connection settings for this Meshtastic Update the hostname or connection settings for this Meshtastic
@ -657,13 +634,13 @@ export const HTTPTab = ({ closeDialog }: HTTPTabProps) => {
onClick={() => setEditServerOpen(false)} onClick={() => setEditServerOpen(false)}
className="flex-1" className="flex-1"
> >
Cancel {t("newDeviceDialog.tabs.actions.cancel")}
</Button> </Button>
<Button <Button
type="submit" type="submit"
className="flex-1" className="flex-1"
> >
Save Changes {t("newDeviceDialog.tabs.actions.saveChanges")}
</Button> </Button>
</div> </div>
</form> </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 { TransportWebSerial } from "@meshtastic/transport-web-serial";
import { AlertTriangle, Circle, Clock, Plus, Trash2, Usb } from "lucide-react"; import { AlertTriangle, Circle, Clock, Plus, Trash2, Usb } from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore/index.ts"; import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
interface SerialTabProps { interface SerialTabProps {
closeDialog: () => void; closeDialog: () => void;
} }
export const SerialTab = ({ closeDialog }: SerialTabProps) => { export const SerialTab = ({ closeDialog }: SerialTabProps) => {
const { t } = useTranslation("dialog");
const [connectionInProgress, setConnectionInProgress] = useState(false); const [connectionInProgress, setConnectionInProgress] = useState(false);
const [connectingToPort, setConnectingToPort] = useState<SerialPort | null>( const [connectingToPort, setConnectingToPort] = useState<SerialPort | null>(
null, null,
@ -27,7 +29,7 @@ export const SerialTab = ({ closeDialog }: SerialTabProps) => {
const updateSerialPortList = useCallback(async () => { const updateSerialPortList = useCallback(async () => {
try { try {
setSerialPorts(await navigator?.serial?.getPorts() || []); setSerialPorts((await navigator?.serial?.getPorts()) ?? []);
} catch (error) { } catch (error) {
console.error("Error getting serial ports:", error); console.error("Error getting serial ports:", error);
} }
@ -124,16 +126,20 @@ export const SerialTab = ({ closeDialog }: SerialTabProps) => {
return ( return (
<Circle className="h-3 w-3 fill-yellow-500 text-yellow-500 animate-spin" /> <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" />; 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) => { const getStatusText = (port: SerialPort) => {
if (connectingToPort === port) return "Connecting..."; if (connectingToPort === port) {
return port.readable !== null ? "Connected" : "Available"; return t("newDeviceDialog.tabs.status.connecting");
}
return port.readable !== null
? t("newDeviceDialog.tabs.status.connected")
: t("newDeviceDialog.tabs.status.available");
}; };
return ( return (
@ -142,7 +148,7 @@ export const SerialTab = ({ closeDialog }: SerialTabProps) => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Usb className="h-5 w-5 text-green-600" /> <Usb className="h-5 w-5 text-green-600" />
<h3 className="font-semibold text-slate-900 dark:text-slate-100"> <h3 className="font-semibold text-slate-900 dark:text-slate-100">
Serial Devices {t("newDeviceDialog.tabs.serial.title")}
</h3> </h3>
</div> </div>
@ -152,16 +158,20 @@ export const SerialTab = ({ closeDialog }: SerialTabProps) => {
? ( ? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400"> <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" /> <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"> <p className="text-xs text-slate-400">
Connect your first Meshtastic device via USB {t("newDeviceDialog.tabs.serial.connectFirst")}
</p> </p>
</div> </div>
) )
: ( : (
serialPorts.map((port, index) => ( serialPorts.map((port, index) => (
<div <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" 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 */} {/* Status */}
@ -194,7 +204,9 @@ export const SerialTab = ({ closeDialog }: SerialTabProps) => {
{connectingToPort === port {connectingToPort === port
? <Clock className="h-3 w-3 mr-1 animate-spin" /> ? <Clock className="h-3 w-3 mr-1 animate-spin" />
: <Usb className="h-3 w-3 mr-1" />} : <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>
<Button <Button
@ -221,13 +233,13 @@ export const SerialTab = ({ closeDialog }: SerialTabProps) => {
? ( ? (
<> <>
<Clock className="h-4 w-4 mr-2 animate-spin" /> <Clock className="h-4 w-4 mr-2 animate-spin" />
Adding... {t("newDeviceDialog.tabs.serial.adding")}
</> </>
) )
: ( : (
<> <>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Add Serial Device {t("newDeviceDialog.tabs.serial.addDevice")}
</> </>
)} )}
</Button> </Button>

9
src/core/stores/appStore.ts

@ -251,12 +251,21 @@ export const useAppStore = create<AppState>()((set, get) => ({
updateServerStatus: ( updateServerStatus: (
url: string, url: string,
status: "online" | "offline" | "checking", status: "online" | "offline" | "checking",
deviceInfo?: {
model?: string;
nodeCount?: number;
unreadCount?: number;
firmwareVersion?: string;
},
) => { ) => {
set( set(
produce<AppState>((draft) => { produce<AppState>((draft) => {
const server = draft.savedServers.find((s) => s.url === url); const server = draft.savedServers.find((s) => s.url === url);
if (server) { if (server) {
server.status = status; server.status = status;
if (deviceInfo) {
server.deviceInfo = deviceInfo;
}
localStorage.setItem( localStorage.setItem(
"meshtastic-saved-servers", "meshtastic-saved-servers",
JSON.stringify(draft.savedServers), 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.", "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.", "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." "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": { "nodeDetails": {

Loading…
Cancel
Save